mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-12 07:46:33 +01:00
Compare commits
229
Commits
eef94eff63
...
v3.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bd29f75e6 | ||
|
|
d7a3a41ab2 | ||
|
|
05e318f9ab | ||
|
|
e8fb0ac564 | ||
|
|
7379f3d93e | ||
|
|
f9151843a6 | ||
|
|
2f95a89682 | ||
|
|
319ff4b645 | ||
|
|
d8ac7fefc7 | ||
|
|
f46f43b6cd | ||
|
|
8052e1db76 | ||
|
|
edc8782302 | ||
|
|
3ffe38d988 | ||
|
|
b76328fc26 | ||
|
|
9feaf54cd2 | ||
|
|
72e1b61bff | ||
|
|
bb3abe39b6 | ||
|
|
be08e50314 | ||
|
|
b9a3ae5ed0 | ||
|
|
47787182d0 | ||
|
|
aca2bd4be2 | ||
|
|
677596c21b | ||
|
|
1dbfb027fe | ||
|
|
8765c5d37b | ||
|
|
2da0d61d4c | ||
|
|
2c6af4b126 | ||
|
|
61774a19ca | ||
|
|
0602f835dc | ||
|
|
45fc169c42 | ||
|
|
e487d2e0b1 | ||
|
|
6f1e1612e1 | ||
|
|
185aaabbc5 | ||
|
|
c1a01b9a47 | ||
|
|
fbd4c0c4fe | ||
|
|
c56a6d4f83 | ||
|
|
5de5b618d1 | ||
|
|
81d45ad0ad | ||
|
|
5583c9c1d6 | ||
|
|
11a304e7ba | ||
|
|
c8de39acb9 | ||
|
|
77e3fa87f1 | ||
|
|
0a5b3512da | ||
|
|
783d7f2ebc | ||
|
|
18118492c7 | ||
|
|
0d1118011f | ||
|
|
6e60ff6183 | ||
|
|
4a85b23383 | ||
|
|
d038a33b36 | ||
|
|
fb1c2a02ff | ||
|
|
d81efe168e | ||
|
|
0bd8f1bda8 | ||
|
|
14ed037996 | ||
|
|
e32df2fb08 | ||
|
|
43f0721d26 | ||
|
|
3f1d9b0d8d | ||
|
|
8348e048b5 | ||
|
|
77190a2af1 | ||
|
|
028685d62b | ||
|
|
cac1f6af24 | ||
|
|
98398ab2fe | ||
|
|
b379a22517 | ||
|
|
d4ba9c0faf | ||
|
|
889bdb452b | ||
|
|
194787dfcc | ||
|
|
2e54d09acb | ||
|
|
4b3a7d1dfa | ||
|
|
bb71f4889d | ||
|
|
915225f812 | ||
|
|
88e83c2a86 | ||
|
|
de64e8bc69 | ||
|
|
0e90f51cfa | ||
|
|
250d02e7ab | ||
|
|
ab32a7bfbe | ||
|
|
8941eb6702 | ||
|
|
63d140e67e | ||
|
|
5758609c25 | ||
|
|
b8b5594d04 | ||
|
|
4f6a292e74 | ||
|
|
5dd6e8dab9 | ||
|
|
733a4d2941 | ||
|
|
3198a94df3 | ||
|
|
8cdbe64942 | ||
|
|
824579c938 | ||
|
|
0ac67a124d | ||
|
|
ce33a2b6fd | ||
|
|
d908b5b26d | ||
|
|
d03dd982b2 | ||
|
|
65d74faa1d | ||
|
|
e0cfba489e | ||
|
|
916da72339 | ||
|
|
87dbabb496 | ||
|
|
746e2319b9 | ||
|
|
4d08b8fb1e | ||
|
|
f1628439be | ||
|
|
562ca1607b | ||
|
|
6b0e705883 | ||
|
|
7161121922 | ||
|
|
a41f77d907 | ||
|
|
9d9550be48 | ||
|
|
c64cdd5172 | ||
|
|
4761772b9a | ||
|
|
0c2e85134b | ||
|
|
7d8a2dae9f | ||
|
|
891406eab9 | ||
|
|
ba77b31dc4 | ||
|
|
ef0c34d973 | ||
|
|
97d33a781a | ||
|
|
6c3028eeb2 | ||
|
|
61a2472ea9 | ||
|
|
9971128078 | ||
|
|
daf305f680 | ||
|
|
fcfc5561ae | ||
|
|
a562e4b316 | ||
|
|
538cb4177a | ||
|
|
b2c66bf03e | ||
|
|
8bcca2767d | ||
|
|
5b6c0ef45c | ||
|
|
d245da20a4 | ||
|
|
730ea11235 | ||
|
|
31859b66b6 | ||
|
|
0b4e1d18bc | ||
|
|
229d6de4c6 | ||
|
|
72784ee914 | ||
|
|
8f34c958fb | ||
|
|
aee631c08d | ||
|
|
62409898c1 | ||
|
|
cf272c4edc | ||
|
|
c92d28c9dd | ||
|
|
7ce754f95a | ||
|
|
5f5ad85dff | ||
|
|
3c0372cceb | ||
|
|
e33784ddd3 | ||
|
|
81e6189ebe | ||
|
|
069a0f7f19 | ||
|
|
86d0d06e76 | ||
|
|
526373c10a | ||
|
|
7b17e33545 | ||
|
|
6cfc1d29f1 | ||
|
|
9cfa4b62e2 | ||
|
|
25dafb0701 | ||
|
|
b019efbef9 | ||
|
|
d171825b5e | ||
|
|
6f997dceb9 | ||
|
|
43ee2d9f27 | ||
|
|
3c184a71a6 | ||
|
|
8396f98a85 | ||
|
|
e396671ac7 | ||
|
|
8e7431cc2e | ||
|
|
7bbc29a1d6 | ||
|
|
0a3e839da0 | ||
|
|
e0a09b724b | ||
|
|
e806eaef89 | ||
|
|
0cef3657bc | ||
|
|
20c6ba526f | ||
|
|
cfb663c250 | ||
|
|
ac1e62fc02 | ||
|
|
adabe21d63 | ||
|
|
0bdac8fa22 | ||
|
|
15d77f998f | ||
|
|
779131c766 | ||
|
|
9695d83b1a | ||
|
|
a8d6111aaa | ||
|
|
d4152ba3a1 | ||
|
|
79fe15da91 | ||
|
|
714440fb25 | ||
|
|
a2d89f28a6 | ||
|
|
b2b02e7c05 | ||
|
|
9d783b2301 | ||
|
|
713d9a976a | ||
|
|
c262e58a96 | ||
|
|
eb5019ea5e | ||
|
|
8fe26a36b1 | ||
|
|
7a890094fc | ||
|
|
4012cc0272 | ||
|
|
336fd9c49f | ||
|
|
5f138bde6a | ||
|
|
32360fac08 | ||
|
|
206ac8988f | ||
|
|
4fad6a78ec | ||
|
|
2439368e4e | ||
|
|
92c6f6945f | ||
|
|
bcd7bbf23a | ||
|
|
ce9e3e89a7 | ||
|
|
66ee837cbb | ||
|
|
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,74 @@
|
|||||||
|
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-
|
||||||
|
- 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
|
||||||
+28
-19
@@ -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-v1
|
||||||
- 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
|
||||||
|
|
||||||
|
|||||||
+101
-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.srcDir("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,18 @@ val integrationTestClasses =
|
|||||||
|
|
||||||
val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
||||||
|
|
||||||
|
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
if (name.endsWith("UnitTest")) {
|
if (name.endsWith("UnitTest")) {
|
||||||
filter {
|
filter {
|
||||||
(integrationTestClasses + demoScreenshotClass).forEach { excludeTestsMatching(it) }
|
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
testLogging {
|
testLogging {
|
||||||
exceptionFormat = TestExceptionFormat.FULL
|
exceptionFormat = TestExceptionFormat.FULL
|
||||||
}
|
}
|
||||||
|
outputs.cacheIf { false }
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register<Test>("integrationTest") {
|
tasks.register<Test>("integrationTest") {
|
||||||
@@ -141,6 +220,20 @@ tasks.register<Test>("demoScreenshots") {
|
|||||||
outputs.upToDateWhen { false }
|
outputs.upToDateWhen { false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.register<Test>("queryTiming") {
|
||||||
|
description = "Measures SQL query timings for the logging threshold (not a CI gate)"
|
||||||
|
group = "verification"
|
||||||
|
val unitTest = tasks.named<Test>("testDebugUnitTest")
|
||||||
|
dependsOn("compileDebugUnitTestKotlin")
|
||||||
|
testClassesDirs = files(provider { unitTest.get().testClassesDirs })
|
||||||
|
classpath = files(provider { unitTest.get().classpath })
|
||||||
|
filter {
|
||||||
|
includeTestsMatching(queryTimingClass)
|
||||||
|
}
|
||||||
|
providers.gradleProperty("measureSongs").orNull?.let { systemProperty("measure.songs", it) }
|
||||||
|
outputs.upToDateWhen { false }
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
@@ -166,7 +259,9 @@ dependencies {
|
|||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
implementation(libs.androidx.media3.session)
|
implementation(libs.androidx.media3.session)
|
||||||
|
implementation(libs.androidx.media3.datasource)
|
||||||
implementation(libs.androidx.media3.datasource.okhttp)
|
implementation(libs.androidx.media3.datasource.okhttp)
|
||||||
|
implementation(libs.androidx.media3.database)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
|
|||||||
@@ -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
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,15 @@ package com.subtracks.data.db
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
fun createAndroidDatabase(context: Context): SubtracksDatabase =
|
fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
||||||
Room
|
val driver = LoggingSQLiteDriver(BundledSQLiteDriver(), Log::sql)
|
||||||
|
return Room
|
||||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||||
.setDriver(BundledSQLiteDriver())
|
.setDriver(driver)
|
||||||
.addMigrations(*MIGRATIONS)
|
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||||
|
.addCallback(ArtistSearchTriggerOptimization)
|
||||||
|
.addCallback(SqliteLifecycleLogging)
|
||||||
.build()
|
.build()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.room3.RoomDatabase
|
||||||
|
import androidx.room3.migration.Migration
|
||||||
|
import androidx.sqlite.SQLiteConnection
|
||||||
|
import androidx.sqlite.SQLiteDriver
|
||||||
|
import androidx.sqlite.SQLiteStatement
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
|
data class SqlExecution(
|
||||||
|
val sql: String,
|
||||||
|
val durationNanos: Long,
|
||||||
|
val rows: Long,
|
||||||
|
val error: Throwable?,
|
||||||
|
) {
|
||||||
|
val durationMillis: Double get() = durationNanos / 1_000_000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoggingSQLiteDriver(
|
||||||
|
private val delegate: SQLiteDriver,
|
||||||
|
private val onExecution: (SqlExecution) -> Unit,
|
||||||
|
) : SQLiteDriver {
|
||||||
|
override val hasConnectionPool: Boolean
|
||||||
|
get() = delegate.hasConnectionPool
|
||||||
|
|
||||||
|
override fun open(fileName: String): SQLiteConnection = LoggingConnection(delegate.open(fileName), onExecution)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LoggingConnection(
|
||||||
|
private val delegate: SQLiteConnection,
|
||||||
|
private val onExecution: (SqlExecution) -> Unit,
|
||||||
|
) : SQLiteConnection by delegate {
|
||||||
|
override fun prepare(sql: String): SQLiteStatement {
|
||||||
|
val start = System.nanoTime()
|
||||||
|
return try {
|
||||||
|
LoggingStatement(delegate.prepare(sql), sql, onExecution)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
onExecution(SqlExecution(sql, System.nanoTime() - start, 0, failure))
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LoggingStatement(
|
||||||
|
private val delegate: SQLiteStatement,
|
||||||
|
private val sql: String,
|
||||||
|
private val onExecution: (SqlExecution) -> Unit,
|
||||||
|
) : SQLiteStatement by delegate {
|
||||||
|
private var startNanos = 0L
|
||||||
|
private var rows = 0L
|
||||||
|
private var failure: Throwable? = null
|
||||||
|
private var active = false
|
||||||
|
|
||||||
|
override fun step(): Boolean {
|
||||||
|
begin()
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
delegate.step()
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
failure = error
|
||||||
|
report()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (result) rows++
|
||||||
|
if (!result) report()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun reset() {
|
||||||
|
report()
|
||||||
|
delegate.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
report()
|
||||||
|
delegate.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun report() {
|
||||||
|
if (!active) return
|
||||||
|
active = false
|
||||||
|
onExecution(SqlExecution(sql, System.nanoTime() - startNanos, rows, failure))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Started on the first step, not on reset: Room's statement cache resets a statement when it is
|
||||||
|
// returned to the cache (BasePreparedStatementCache.CachedStatement.close), so timing from reset
|
||||||
|
// would count however long the statement idled there before its next use.
|
||||||
|
private fun begin() {
|
||||||
|
if (active) return
|
||||||
|
startNanos = System.nanoTime()
|
||||||
|
rows = 0
|
||||||
|
failure = null
|
||||||
|
active = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoggedMigration(
|
||||||
|
private val delegate: Migration,
|
||||||
|
) : Migration(delegate.startVersion, delegate.endVersion) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
val label = "${delegate.startVersion} -> ${delegate.endVersion}"
|
||||||
|
Log.i("sql", "migrating $label")
|
||||||
|
val start = System.nanoTime()
|
||||||
|
try {
|
||||||
|
delegate.migrate(connection)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
Log.e("sql", "migration $label failed after ${(System.nanoTime() - start) / 1_000_000}ms", failure)
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
Log.i("sql", "migrated $label in ${(System.nanoTime() - start) / 1_000_000}ms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object ArtistSearchTriggerOptimization : RoomDatabase.Callback() {
|
||||||
|
override suspend fun onCreate(connection: SQLiteConnection) {
|
||||||
|
optimizeArtistSearchTriggers(connection)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onOpen(connection: SQLiteConnection) {
|
||||||
|
if (!artistSearchTriggersOptimized(connection)) optimizeArtistSearchTriggers(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object SqliteLifecycleLogging : RoomDatabase.Callback() {
|
||||||
|
override suspend fun onCreate(connection: SQLiteConnection) {
|
||||||
|
Log.i("sql", "created fresh database")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onOpen(connection: SQLiteConnection) {
|
||||||
|
Log.i("sql", "database opened")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onDestructiveMigration(connection: SQLiteConnection) {
|
||||||
|
Log.w("sql", "destructive migration: dropping all tables")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,18 +13,43 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
|
|
||||||
// A song is attributed to its album artist and to its credited track artist, so a guest artist's
|
// A song is attributed to its album artist and to its credited track artist, so a guest artist's
|
||||||
// downloads are visible even though they lead no albums of their own.
|
// downloads are visible even though they lead no albums of their own.
|
||||||
private const val ARTIST_DOWNLOAD_ROWS =
|
// Starts from songs so status totals include every song the artist is credited on.
|
||||||
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
internal const val ARTIST_DOWNLOAD_ROWS =
|
||||||
"FROM songs " +
|
"SELECT credits.artistId AS id, credits.songId AS songId, song_downloads.status AS status " +
|
||||||
|
"FROM (" + CREDITED_SONG_ROWS + ") credits " +
|
||||||
|
"LEFT JOIN song_downloads ON song_downloads.sourceId = :sourceId " +
|
||||||
|
"AND song_downloads.songId = credits.songId"
|
||||||
|
|
||||||
|
// Starts from downloads so lists and filters over partially downloaded libraries do not scan songs.
|
||||||
|
// Kept hand-written for that plan.
|
||||||
|
internal const val DOWNLOADED_ARTIST_ROWS =
|
||||||
|
"SELECT albums.artistId AS id, song_downloads.songId AS songId, " +
|
||||||
|
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||||
|
"FROM song_downloads " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
"WHERE song_downloads.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
"UNION ALL " +
|
||||||
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
"SELECT album_artists.artistId AS id, song_downloads.songId AS songId, " +
|
||||||
"FROM songs " +
|
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||||
|
"FROM song_downloads " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL"
|
"WHERE song_downloads.sourceId = :sourceId " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT songs.artistId AS id, song_downloads.songId AS songId, " +
|
||||||
|
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||||
|
"FROM song_downloads " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||||
|
"WHERE song_downloads.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT song_artists.artistId AS id, song_downloads.songId AS songId, " +
|
||||||
|
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||||
|
"FROM song_downloads " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||||
|
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
|
||||||
|
"WHERE song_downloads.sourceId = :sourceId"
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface DownloadDao {
|
interface DownloadDao {
|
||||||
@@ -38,10 +63,15 @@ interface DownloadDao {
|
|||||||
suspend fun all(): List<SongDownload>
|
suspend fun all(): List<SongDownload>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT COUNT(DISTINCT songId) AS total, " +
|
"SELECT COALESCE((SELECT songCount FROM artists " +
|
||||||
|
"WHERE sourceId = :sourceId AND id = :artistId), 0) AS total, " +
|
||||||
|
"COALESCE(statuses.downloaded, 0) AS downloaded, " +
|
||||||
|
"COALESCE(statuses.downloading, 0) AS downloading " +
|
||||||
|
"FROM (SELECT " +
|
||||||
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
||||||
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
||||||
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") WHERE id = :artistId",
|
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
|
||||||
|
"WHERE id = :artistId AND status IN ('Completed', 'Queued', 'Running')) statuses",
|
||||||
)
|
)
|
||||||
fun artistStatus(
|
fun artistStatus(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -89,12 +119,15 @@ interface DownloadDao {
|
|||||||
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT id, " +
|
"SELECT statuses.id, artists.songCount AS total, statuses.downloaded, statuses.downloading " +
|
||||||
"COUNT(DISTINCT songId) AS total, " +
|
"FROM (SELECT id, " +
|
||||||
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
||||||
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
||||||
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") GROUP BY id " +
|
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
|
||||||
"HAVING downloaded > 0 OR downloading > 0",
|
"WHERE status IN ('Completed', 'Queued', 'Running') " +
|
||||||
|
"GROUP BY id) statuses " +
|
||||||
|
"JOIN artists ON artists.sourceId = :sourceId AND artists.id = statuses.id " +
|
||||||
|
"WHERE statuses.downloaded > 0 OR statuses.downloading > 0",
|
||||||
)
|
)
|
||||||
fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
||||||
|
|
||||||
@@ -114,24 +147,30 @@ interface DownloadDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
|
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
|
||||||
"al.artistId AS artistId, ar.name AS artistName, d.status AS status, " +
|
"al.artistId AS artistId, COALESCE(ar.name, al.albumArtist) AS artistName, d.status AS status, " +
|
||||||
"d.bytes AS bytes, d.total AS total " +
|
"d.bytes AS bytes, d.total AS total, " +
|
||||||
|
"COALESCE(NULLIF(ar.sortName, ''), NULLIF(al.albumArtistSort, ''), ar.name, al.albumArtist, '') AS artistSort, " +
|
||||||
|
"COALESCE(NULLIF(al.sortName, ''), al.name, '') AS albumSort, " +
|
||||||
|
"COALESCE(NULLIF(s.sortName, ''), s.title, '') AS songSort " +
|
||||||
"FROM song_downloads d " +
|
"FROM song_downloads d " +
|
||||||
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
||||||
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
||||||
"LEFT JOIN artists ar ON ar.sourceId = al.sourceId AND ar.id = al.artistId " +
|
"LEFT JOIN artists ar ON ar.sourceId = al.sourceId AND ar.id = al.artistId " +
|
||||||
"WHERE d.sourceId = :sourceId",
|
"WHERE d.sourceId = :sourceId ORDER BY s.disc, s.track, s.id",
|
||||||
)
|
)
|
||||||
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
||||||
|
"al.artistId AS albumArtistId, " +
|
||||||
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
||||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt " +
|
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
|
||||||
|
"aar.coverArt AS albumArtistCoverArt " +
|
||||||
"FROM song_downloads d " +
|
"FROM song_downloads d " +
|
||||||
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
||||||
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
||||||
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId",
|
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId " +
|
||||||
|
"LEFT JOIN artists aar ON aar.sourceId = al.sourceId AND aar.id = al.artistId",
|
||||||
)
|
)
|
||||||
suspend fun artwork(): List<DownloadArtwork>
|
suspend fun artwork(): List<DownloadArtwork>
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
|
|||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.DiscKey
|
import com.subtracks.data.model.DiscKey
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.Source
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
internal const val PLAYLIST_SONGS_SELECT =
|
internal const val PLAYLIST_SONGS_SELECT =
|
||||||
@@ -44,72 +46,305 @@ internal const val ALBUMS_FILTER =
|
|||||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
||||||
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
||||||
"OR instr(lower(albumArtist), lower(:search)) > 0))) "
|
"OR instr(lower(albumArtist), lower(:search)) > 0 " +
|
||||||
|
"OR instr(lower(sortName), lower(:search)) > 0 " +
|
||||||
|
"OR instr(lower(albumArtistSort), lower(:search)) > 0))) "
|
||||||
|
|
||||||
internal const val ALBUM_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ALBUM_ORDER_BY_ARTIST = "albumArtist COLLATE NOCASE, year, name COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_ARTIST_REVERSED = "albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, name COLLATE NOCASE, id"
|
|
||||||
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, name COLLATE NOCASE DESC, id DESC"
|
|
||||||
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, name COLLATE NOCASE, id"
|
|
||||||
internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE NOCASE DESC, id DESC"
|
|
||||||
|
|
||||||
// Starred orders keep unstarred rows last in both directions, matching the list grouping, so the
|
// Starred orders keep unstarred rows last in both directions, matching the list grouping, so the
|
||||||
// reversed order is not a literal mirror of the base order.
|
// reversed order is not a literal mirror of the base order.
|
||||||
internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
|
|
||||||
internal const val ALBUM_ORDER_BY_FREQUENT = "playCount DESC, name COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_FREQUENT = "playCount DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, name COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, name COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
|
|
||||||
|
private const val ALBUM_NAME_SORT_KEY_MODE =
|
||||||
|
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||||
|
private const val ALBUM_ARTIST_SORT_KEY_MODE =
|
||||||
|
"(CASE :sortMode WHEN 'Name' THEN albumArtist WHEN 'NonLatin' THEN (CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) ELSE albumArtistSort END) COLLATE NOCASE"
|
||||||
|
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id"
|
||||||
|
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||||
|
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE = "$ALBUM_ARTIST_SORT_KEY_MODE, year, $ALBUM_NAME_SORT_KEY_MODE, id"
|
||||||
|
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED =
|
||||||
|
"$ALBUM_ARTIST_SORT_KEY_MODE DESC, year DESC, $ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||||
|
|
||||||
|
internal const val ARTIST_CREDITED =
|
||||||
|
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
||||||
|
|
||||||
|
// May repeat a (artistId, songId) pair when several credit paths reach it; consumers dedupe.
|
||||||
|
internal const val CREDITED_SONG_ROWS =
|
||||||
|
"SELECT songs.artistId AS artistId, songs.id AS songId, songs.albumId AS albumId, " +
|
||||||
|
"songs.playCount AS playCount, songs.played AS played " +
|
||||||
|
"FROM songs WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT song_artists.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"WHERE song_artists.sourceId = :sourceId " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT albums.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
|
||||||
|
"FROM albums JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
|
||||||
|
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT album_artists.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
|
||||||
|
"FROM album_artists JOIN songs " +
|
||||||
|
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId"
|
||||||
|
|
||||||
|
// May repeat an (artistId, albumId) pair when several credit paths reach it; consumers dedupe.
|
||||||
|
internal const val CREDITED_ALBUM_ROWS =
|
||||||
|
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||||
|
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT album_artists.artistId, albums.id, 'album' " +
|
||||||
|
"FROM album_artists JOIN albums " +
|
||||||
|
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT songs.artistId, songs.albumId, 'track' " +
|
||||||
|
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE song_artists.sourceId = :sourceId"
|
||||||
|
|
||||||
|
internal const val ALBUMS_FOR_ARTIST =
|
||||||
|
"SELECT albums.* FROM (SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId) credits " +
|
||||||
|
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||||
|
"ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
||||||
|
|
||||||
|
internal const val ALBUM_CREDIT_ALBUM_IDS =
|
||||||
|
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
|
||||||
|
"WHERE artistId = :artistId AND creditType = 'album'"
|
||||||
|
|
||||||
|
// Download visibility follows the credit path: an album credit includes the album when any song is
|
||||||
|
// downloaded, while a track credit includes it only when the credited track is downloaded.
|
||||||
|
internal const val DOWNLOADED_ALBUM_ROWS =
|
||||||
|
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
||||||
|
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM songs " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT albums.id, album_artists.artistId FROM album_artists " +
|
||||||
|
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM songs " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT songs.albumId, songs.artistId FROM songs " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
|
||||||
|
|
||||||
|
private const val DOWNLOADED_ARTIST_ALBUMS =
|
||||||
|
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
|
||||||
|
|
||||||
|
private const val DOWNLOADED_COVER_ALBUM_ROWS =
|
||||||
|
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
||||||
|
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM songs " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT albums.id, album_artists.artistId FROM album_artists " +
|
||||||
|
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM songs " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT songs.albumId, songs.artistId FROM songs " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||||
|
"AND sd.status = 'Completed' " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL AND sd.status = 'Completed'"
|
||||||
|
|
||||||
|
// Cover art only needs credited albums that actually have a cover, so filter before the window sort.
|
||||||
|
private const val ARTIST_COVER_CREDITS =
|
||||||
|
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||||
|
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||||
|
"AND albums.coverArt IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT album_artists.artistId, albums.id, 'album' " +
|
||||||
|
"FROM album_artists JOIN albums " +
|
||||||
|
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT songs.artistId, songs.albumId, 'track' " +
|
||||||
|
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL"
|
||||||
|
|
||||||
|
private const val ARTIST_COVER_CREDITS_FOR_ARTIST =
|
||||||
|
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||||
|
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||||
|
"AND albums.coverArt IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT album_artists.artistId, albums.id, 'album' " +
|
||||||
|
"FROM album_artists JOIN albums " +
|
||||||
|
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
||||||
|
"AND albums.coverArt IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT songs.artistId, songs.albumId, 'track' " +
|
||||||
|
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND albums.coverArt IS NOT NULL " +
|
||||||
|
"UNION ALL " +
|
||||||
|
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
||||||
|
"AND albums.coverArt IS NOT NULL"
|
||||||
|
|
||||||
|
private const val ARTIST_COVER_RANKS_TAIL =
|
||||||
|
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
|
||||||
|
"ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||||
|
"FROM credits JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||||
|
"WHERE albums.coverArt IS NOT NULL), " +
|
||||||
|
"unique_ranked AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM ranked GROUP BY artistId, coverArt), " +
|
||||||
|
"deduped AS (SELECT artistId, coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
|
||||||
|
|
||||||
|
private const val ARTIST_COVER_RANKS =
|
||||||
|
"credits AS (SELECT artistId, albumId, " +
|
||||||
|
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||||
|
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
||||||
|
ARTIST_COVER_RANKS_TAIL
|
||||||
|
|
||||||
|
private const val ARTIST_COVER_RANKS_FOR_ARTIST =
|
||||||
|
"credits AS (SELECT artistId, albumId, " +
|
||||||
|
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||||
|
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
|
||||||
|
ARTIST_COVER_RANKS_TAIL
|
||||||
|
|
||||||
|
internal const val ARTIST_ART_UPDATE_SQL =
|
||||||
|
"WITH $ARTIST_COVER_RANKS, " +
|
||||||
|
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||||
|
"SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||||
|
") GROUP BY artistId), " +
|
||||||
|
"downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS)), " +
|
||||||
|
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
|
||||||
|
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||||
|
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
|
||||||
|
"WHERE albums.coverArt IS NOT NULL), " +
|
||||||
|
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
|
||||||
|
"downloaded_deduped AS (SELECT artistId, coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
|
||||||
|
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||||
|
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||||
|
") GROUP BY artistId), " +
|
||||||
|
"desired AS (SELECT artists.rowid AS rowid, " +
|
||||||
|
"COALESCE((SELECT coverArts FROM covers WHERE covers.artistId = artists.id), '') AS coverArts, " +
|
||||||
|
"COALESCE((SELECT coverArts FROM downloaded_covers " +
|
||||||
|
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
|
||||||
|
"FROM artists WHERE artists.sourceId = :sourceId) " +
|
||||||
|
"UPDATE artists SET coverArts = desired.coverArts, downloadedCoverArts = desired.downloadedCoverArts " +
|
||||||
|
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||||
|
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
|
||||||
|
|
||||||
|
internal const val ARTIST_COVER_ARTS_SQL =
|
||||||
|
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
|
||||||
|
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
|
||||||
|
|
||||||
|
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||||
|
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
|
||||||
|
"WHERE artistId IN (:artistIds)), " +
|
||||||
|
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
|
||||||
|
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
|
||||||
|
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
|
||||||
|
"WHERE albums.coverArt IS NOT NULL), " +
|
||||||
|
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
|
||||||
|
"downloaded_deduped AS (SELECT artistId, coverArt, " +
|
||||||
|
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
|
||||||
|
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||||
|
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||||
|
") GROUP BY artistId), " +
|
||||||
|
"desired AS (SELECT artists.rowid AS rowid, " +
|
||||||
|
"COALESCE((SELECT coverArts FROM downloaded_covers " +
|
||||||
|
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
|
||||||
|
"FROM artists WHERE artists.sourceId = :sourceId AND artists.id IN (:artistIds)) " +
|
||||||
|
"UPDATE artists SET downloadedCoverArts = desired.downloadedCoverArts " +
|
||||||
|
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||||
|
"artists.downloadedCoverArts != desired.downloadedCoverArts"
|
||||||
|
|
||||||
internal const val ARTISTS_FILTER =
|
internal const val ARTISTS_FILTER =
|
||||||
"FROM artists WHERE sourceId = :sourceId " +
|
"FROM artists WHERE sourceId = :sourceId " +
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
||||||
"AND (:downloadedFilter = 0 OR " +
|
"AND $ARTIST_CREDITED " +
|
||||||
"EXISTS (SELECT 1 FROM songs " +
|
"AND (:downloadedFilter = 0 OR EXISTS (" +
|
||||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
"SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl " +
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
"WHERE dl.id = artists.id AND dl.status = 'Completed')) " +
|
||||||
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed') " +
|
|
||||||
"OR EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed')) " +
|
|
||||||
"AND (:search = '' " +
|
"AND (:search = '' " +
|
||||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||||
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||||
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) "
|
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
||||||
|
"OR instr(lower(sortName), lower(:search)) > 0))) "
|
||||||
|
|
||||||
|
internal const val ARTIST_COLUMNS =
|
||||||
|
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
|
||||||
|
"artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||||
|
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited"
|
||||||
|
|
||||||
// With the Downloaded filter on, count the albums that actually have a download so the number under
|
// With the Downloaded filter on, count the albums that actually have a download so the number under
|
||||||
// an artist matches the albums its detail screen lists.
|
// an artist matches the albums its detail screen lists.
|
||||||
internal const val ARTISTS_SELECT =
|
internal const val ARTISTS_SELECT =
|
||||||
"SELECT artists.sourceId, artists.id, artists.name, " +
|
"SELECT artists.sourceId, artists.id, artists.name, " +
|
||||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
||||||
"SELECT COUNT(*) FROM (" +
|
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
|
||||||
"SELECT dl.id FROM songs " +
|
") ELSE artists.albumCount END AS albumCount, " +
|
||||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited "
|
||||||
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed' " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT dl.id FROM songs " +
|
|
||||||
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed'" +
|
|
||||||
")) ELSE artists.albumCount END AS albumCount, " +
|
|
||||||
"artists.starred, artists.coverArt, artists.playCount, artists.played "
|
|
||||||
|
|
||||||
internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, name COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, name COLLATE NOCASE DESC, id DESC"
|
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, name COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||||
internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, name COLLATE NOCASE DESC, id DESC"
|
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, name COLLATE NOCASE, id"
|
|
||||||
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
private const val ARTIST_NAME_SORT_KEY_MODE =
|
||||||
|
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||||
|
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE = "$ARTIST_NAME_SORT_KEY_MODE, id"
|
||||||
|
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ARTIST_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||||
|
|
||||||
|
private const val SONG_NAME_SORT_KEY_MODE =
|
||||||
|
"(CASE :sortMode WHEN 'Name' THEN songs.title WHEN 'NonLatin' THEN (CASE WHEN songs.title GLOB '*[A-Za-z]*' THEN songs.title ELSE songs.sortName END) ELSE songs.sortName END) COLLATE NOCASE"
|
||||||
|
internal const val SONG_SEARCH_ORDER_SORT_MODE = "$SONG_NAME_SORT_KEY_MODE, songs.id"
|
||||||
|
|
||||||
internal const val PLAYLISTS_FILTER =
|
internal const val PLAYLISTS_FILTER =
|
||||||
"FROM playlists WHERE sourceId = :sourceId " +
|
"FROM playlists WHERE sourceId = :sourceId " +
|
||||||
@@ -130,7 +365,7 @@ internal const val PLAYLISTS_SELECT =
|
|||||||
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " +
|
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " +
|
||||||
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
|
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
|
||||||
") ELSE playlists.songCount END AS songCount, " +
|
") ELSE playlists.songCount END AS songCount, " +
|
||||||
"playlists.created, playlists.changed, playlists.duration "
|
"playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared "
|
||||||
|
|
||||||
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
||||||
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
||||||
@@ -191,26 +426,63 @@ interface LibraryDao {
|
|||||||
)
|
)
|
||||||
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
||||||
|
|
||||||
// INDEXED BY is required: the planner otherwise answers MAX(played) from index_albums_recent
|
// Counts, plays and the credited flag come from the shared credit unions. The download-aware
|
||||||
// (sourceId, played), which cannot apply the artistId equality.
|
// queries are separate because their visibility follows the credit path.
|
||||||
@Query(
|
@Query(
|
||||||
"UPDATE artists SET " +
|
"WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " +
|
||||||
"playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"credited_albums AS ($CREDITED_ALBUM_ROWS), " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0), " +
|
"totals AS (" +
|
||||||
"played = (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id) " +
|
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||||
"WHERE sourceId = :sourceId AND (" +
|
"FROM credited GROUP BY artistId" +
|
||||||
"playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"), album_totals AS (" +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0) " +
|
"SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||||
"OR played IS NOT (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
"FROM credited_albums GROUP BY artistId" +
|
||||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id))",
|
") " +
|
||||||
|
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||||
|
"playCount = c.playCount, played = c.played, credited = c.credited " +
|
||||||
|
"FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " +
|
||||||
|
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||||
|
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " +
|
||||||
|
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
|
||||||
|
"OR EXISTS (SELECT 1 FROM album_artists aa " +
|
||||||
|
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
|
||||||
|
"OR EXISTS (SELECT 1 FROM song_artists sa " +
|
||||||
|
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
|
||||||
|
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
||||||
|
"LEFT JOIN album_totals ON album_totals.id = artists.id " +
|
||||||
|
"WHERE artists.sourceId = :sourceId) c " +
|
||||||
|
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
||||||
|
"AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " +
|
||||||
|
"OR artists.playCount != c.playCount OR artists.played IS NOT c.played " +
|
||||||
|
"OR artists.credited != c.credited)",
|
||||||
)
|
)
|
||||||
suspend fun recomputeArtistPlayData(sourceId: Long)
|
suspend fun recomputeArtistAggregates(sourceId: Long)
|
||||||
|
|
||||||
|
@Query(ARTIST_ART_UPDATE_SQL)
|
||||||
|
suspend fun recomputeArtistArt(sourceId: Long)
|
||||||
|
|
||||||
|
@Query(ARTIST_COVER_ARTS_SQL)
|
||||||
|
fun artistCoverArts(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<List<String>>
|
||||||
|
|
||||||
|
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
|
||||||
|
suspend fun recomputeDownloadedArtistArt(
|
||||||
|
sourceId: Long,
|
||||||
|
artistIds: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
|
||||||
|
suspend fun clearDownloadedArtistArt(sourceId: Long)
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
suspend fun recomputePlayData(sourceId: Long) {
|
suspend fun recomputeAggregates(sourceId: Long) {
|
||||||
recomputeAlbumPlayData(sourceId)
|
recomputeAlbumPlayData(sourceId)
|
||||||
recomputeArtistPlayData(sourceId)
|
recomputeArtistAggregates(sourceId)
|
||||||
|
recomputeArtistArt(sourceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -235,11 +507,16 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
|
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
|
||||||
"WHERE sourceId = :sourceId AND id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId)",
|
"WHERE sourceId = :sourceId AND (" +
|
||||||
|
"id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId) " +
|
||||||
|
"OR id IN (SELECT artistId FROM album_artists WHERE sourceId = :sourceId AND albumId = :albumId) " +
|
||||||
|
"OR id = (SELECT artistId FROM songs WHERE sourceId = :sourceId AND id = :songId) " +
|
||||||
|
"OR id IN (SELECT artistId FROM song_artists WHERE sourceId = :sourceId AND songId = :songId))",
|
||||||
)
|
)
|
||||||
suspend fun bumpArtistPlay(
|
suspend fun bumpArtistPlay(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String?,
|
||||||
|
songId: String,
|
||||||
at: Long,
|
at: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -249,6 +526,12 @@ interface LibraryDao {
|
|||||||
songId: String,
|
songId: String,
|
||||||
): String?
|
): String?
|
||||||
|
|
||||||
|
@Query("SELECT DISTINCT artistId FROM ($CREDITED_SONG_ROWS) WHERE songId IN (:songIds)")
|
||||||
|
suspend fun creditedArtistIdsForSongs(
|
||||||
|
sourceId: Long,
|
||||||
|
songIds: List<String>,
|
||||||
|
): List<String>
|
||||||
|
|
||||||
// Counted locally when a scrobble is submitted, so the play sorts move before the next sync; the
|
// Counted locally when a scrobble is submitted, so the play sorts move before the next sync; the
|
||||||
// sync then overwrites with the server's own number.
|
// sync then overwrites with the server's own number.
|
||||||
@Transaction
|
@Transaction
|
||||||
@@ -258,9 +541,9 @@ interface LibraryDao {
|
|||||||
at: Long,
|
at: Long,
|
||||||
) {
|
) {
|
||||||
bumpSongPlay(sourceId, songId, at)
|
bumpSongPlay(sourceId, songId, at)
|
||||||
val albumId = albumIdForSong(sourceId, songId) ?: return
|
val albumId = albumIdForSong(sourceId, songId)
|
||||||
bumpAlbumPlay(sourceId, albumId, at)
|
if (albumId != null) bumpAlbumPlay(sourceId, albumId, at)
|
||||||
bumpArtistPlay(sourceId, albumId, at)
|
bumpArtistPlay(sourceId, albumId, songId, at)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Albums, not songs: these flows re-run on every write to their table, and songs are written
|
// Albums, not songs: these flows re-run on every write to their table, and songs are written
|
||||||
@@ -273,7 +556,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL " +
|
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL " +
|
||||||
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun recentlyPlayedAlbums(
|
fun recentlyPlayedAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -281,8 +564,8 @@ interface LibraryDao {
|
|||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL " +
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun recentlyPlayedArtists(
|
fun recentlyPlayedArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -291,7 +574,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 " +
|
"SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 " +
|
||||||
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun mostPlayedAlbums(
|
fun mostPlayedAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -299,8 +582,8 @@ interface LibraryDao {
|
|||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 " +
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun mostPlayedArtists(
|
fun mostPlayedArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -309,7 +592,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
"SELECT * FROM albums WHERE sourceId = :sourceId " +
|
||||||
"ORDER BY created DESC, name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY created DESC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun recentlyAddedAlbums(
|
fun recentlyAddedAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -318,7 +601,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL AND played < :cutoff " +
|
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL AND played < :cutoff " +
|
||||||
"ORDER BY played ASC, name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY played ASC, sortName COLLATE NOCASE, id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun rediscoverAlbums(
|
fun rediscoverAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -330,7 +613,7 @@ interface LibraryDao {
|
|||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
||||||
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit",
|
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun recentlyStarredSongs(
|
fun recentlyStarredSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -339,22 +622,24 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
|
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
|
||||||
"ORDER BY name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun searchAlbums(
|
fun searchAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
sortMode: String = "",
|
||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER " +
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
|
||||||
"ORDER BY name COLLATE NOCASE, id LIMIT :limit",
|
"ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun searchArtists(
|
fun searchArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
sortMode: String = "",
|
||||||
): Flow<List<Artist>>
|
): Flow<List<Artist>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -371,12 +656,13 @@ interface LibraryDao {
|
|||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
|
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
|
||||||
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit",
|
"ORDER BY $SONG_SEARCH_ORDER_SORT_MODE LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun searchSongs(
|
fun searchSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
|
sortMode: String = "",
|
||||||
): Flow<List<AlbumSongItem>>
|
): Flow<List<AlbumSongItem>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -510,42 +796,48 @@ interface LibraryDao {
|
|||||||
@RawQuery(observedEntities = [Album::class])
|
@RawQuery(observedEntities = [Album::class])
|
||||||
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
||||||
|
|
||||||
@RawQuery(observedEntities = [Artist::class])
|
// The fallback art reads albums and credits, but a successful sync writes sources after the
|
||||||
|
// library changes, so observing artists and sources avoids invalidating on every song/album write.
|
||||||
|
@RawQuery(observedEntities = [Artist::class, Source::class])
|
||||||
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
|
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@RawQuery(observedEntities = [Playlist::class])
|
@RawQuery(observedEntities = [Playlist::class])
|
||||||
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME")
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE")
|
||||||
fun albumsByName(
|
fun albumsByName(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED")
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED")
|
||||||
fun albumsByNameReversed(
|
fun albumsByNameReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST")
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE")
|
||||||
fun albumsByArtist(
|
fun albumsByArtist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED")
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED")
|
||||||
fun albumsByArtistReversed(
|
fun albumsByArtistReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
|
||||||
@@ -628,20 +920,22 @@ interface LibraryDao {
|
|||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME")
|
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE")
|
||||||
fun artistsByName(
|
fun artistsByName(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Artist>
|
): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED")
|
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED")
|
||||||
fun artistsByNameReversed(
|
fun artistsByNameReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
|
sortMode: String = "",
|
||||||
): PagingSource<Int, Artist>
|
): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
|
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
|
||||||
@@ -750,32 +1044,38 @@ interface LibraryDao {
|
|||||||
downloadedFilter: Int = 0,
|
downloadedFilter: Int = 0,
|
||||||
): PagingSource<Int, Playlist>
|
): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id")
|
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id")
|
||||||
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id")
|
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, sortName COLLATE NOCASE, id")
|
||||||
fun homeMostPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
fun homeMostPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE, id")
|
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, sortName COLLATE NOCASE, id")
|
||||||
fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource<Int, Album>
|
fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id")
|
@Query(
|
||||||
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
|
||||||
|
"ORDER BY played DESC, sortName COLLATE NOCASE, id",
|
||||||
|
)
|
||||||
fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id")
|
@Query(
|
||||||
|
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
|
||||||
|
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id",
|
||||||
|
)
|
||||||
fun homeMostPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
fun homeMostPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
||||||
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id",
|
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||||
)
|
)
|
||||||
fun starredSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
|
fun starredSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
||||||
"ORDER BY starred DESC, title COLLATE NOCASE, id",
|
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
|
||||||
)
|
)
|
||||||
suspend fun starredSongIds(sourceId: Long): List<String>
|
suspend fun starredSongIds(sourceId: Long): List<String>
|
||||||
|
|
||||||
@@ -784,7 +1084,7 @@ interface LibraryDao {
|
|||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit",
|
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
|
||||||
)
|
)
|
||||||
fun downloadedSongs(
|
fun downloadedSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -795,7 +1095,7 @@ interface LibraryDao {
|
|||||||
"SELECT songs.id FROM songs " +
|
"SELECT songs.id FROM songs " +
|
||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
|
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||||
)
|
)
|
||||||
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
||||||
|
|
||||||
@@ -804,7 +1104,7 @@ interface LibraryDao {
|
|||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
|
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||||
)
|
)
|
||||||
fun homeDownloadedSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
|
fun homeDownloadedSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
|
||||||
|
|
||||||
@@ -814,28 +1114,16 @@ interface LibraryDao {
|
|||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
|
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
|
||||||
"WHERE albums.sourceId = :sourceId " +
|
"WHERE albums.sourceId = :sourceId " +
|
||||||
"GROUP BY albums.sourceId, albums.id " +
|
"GROUP BY albums.sourceId, albums.id " +
|
||||||
"ORDER BY MAX(d.downloadedAt) DESC, albums.name COLLATE NOCASE, albums.id",
|
"ORDER BY MAX(d.downloadedAt) DESC, albums.sortName COLLATE NOCASE, albums.id",
|
||||||
)
|
)
|
||||||
fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album>
|
fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT artists.* FROM artists " +
|
"SELECT $ARTIST_COLUMNS FROM artists " +
|
||||||
"JOIN (" +
|
"JOIN (SELECT id, MAX(downloadedAt) AS lastDownloadedAt FROM ($DOWNLOADED_ARTIST_ROWS) " +
|
||||||
"SELECT albums.artistId AS artistId, d.downloadedAt AS downloadedAt " +
|
"WHERE status = 'Completed' GROUP BY id) dl ON dl.id = artists.id " +
|
||||||
"FROM songs " +
|
"WHERE artists.sourceId = :sourceId AND $ARTIST_CREDITED " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"ORDER BY dl.lastDownloadedAt DESC, artists.sortName COLLATE NOCASE, artists.id",
|
||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT songs.artistId AS artistId, d.downloadedAt AS downloadedAt " +
|
|
||||||
"FROM songs " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL" +
|
|
||||||
") dl ON dl.artistId = artists.id " +
|
|
||||||
"WHERE artists.sourceId = :sourceId " +
|
|
||||||
"GROUP BY artists.sourceId, artists.id " +
|
|
||||||
"ORDER BY MAX(dl.downloadedAt) DESC, artists.name COLLATE NOCASE, artists.id",
|
|
||||||
)
|
)
|
||||||
fun downloadedArtists(sourceId: Long): PagingSource<Int, Artist>
|
fun downloadedArtists(sourceId: Long): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@@ -844,7 +1132,7 @@ interface LibraryDao {
|
|||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||||
"ORDER BY songs.title COLLATE NOCASE, songs.id",
|
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
|
||||||
)
|
)
|
||||||
fun songsByGenre(
|
fun songsByGenre(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -855,7 +1143,7 @@ interface LibraryDao {
|
|||||||
"SELECT songs.id FROM song_genres " +
|
"SELECT songs.id FROM song_genres " +
|
||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||||
"ORDER BY songs.title COLLATE NOCASE, songs.id",
|
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
|
||||||
)
|
)
|
||||||
suspend fun songsByGenreIds(
|
suspend fun songsByGenreIds(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -866,12 +1154,12 @@ interface LibraryDao {
|
|||||||
"SELECT COUNT(*) FROM song_genres " +
|
"SELECT COUNT(*) FROM song_genres " +
|
||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||||
"AND (songs.title COLLATE NOCASE < :title OR (songs.title COLLATE NOCASE = :title AND songs.id < :id))",
|
"AND (songs.sortName COLLATE NOCASE < :sortName OR (songs.sortName COLLATE NOCASE = :sortName AND songs.id < :id))",
|
||||||
)
|
)
|
||||||
suspend fun genreSongOrdinal(
|
suspend fun genreSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
genre: String,
|
genre: String,
|
||||||
title: String,
|
sortName: String,
|
||||||
id: String,
|
id: String,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
@@ -881,8 +1169,8 @@ interface LibraryDao {
|
|||||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
"AND (d.downloadedAt > (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
|
"AND (d.downloadedAt > (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
|
||||||
"OR (d.downloadedAt = (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
|
"OR (d.downloadedAt = (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
|
||||||
"AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
"AND (s.sortName COLLATE NOCASE < (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
||||||
"OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
|
"OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
|
||||||
)
|
)
|
||||||
suspend fun downloadedSongOrdinal(
|
suspend fun downloadedSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -894,8 +1182,8 @@ interface LibraryDao {
|
|||||||
"WHERE s.sourceId = :sourceId AND s.starred IS NOT NULL " +
|
"WHERE s.sourceId = :sourceId AND s.starred IS NOT NULL " +
|
||||||
"AND (s.starred > (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
"AND (s.starred > (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
||||||
"OR (s.starred = (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
"OR (s.starred = (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
||||||
"AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
"AND (s.sortName COLLATE NOCASE < (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) " +
|
||||||
"OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
|
"OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
|
||||||
)
|
)
|
||||||
suspend fun starredSongOrdinal(
|
suspend fun starredSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -904,7 +1192,7 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " +
|
"SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " +
|
||||||
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
"ORDER BY year DESC, sortName COLLATE NOCASE, id",
|
||||||
)
|
)
|
||||||
fun albumsByDecade(
|
fun albumsByDecade(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -930,22 +1218,40 @@ interface LibraryDao {
|
|||||||
albumId: String,
|
albumId: String,
|
||||||
): Album?
|
): Album?
|
||||||
|
|
||||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
@Query("SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
||||||
suspend fun artistOnce(
|
suspend fun artistOnce(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Artist?
|
): Artist?
|
||||||
|
|
||||||
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
@Query("SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND id = :artistId")
|
||||||
fun artist(
|
fun artist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<Artist?>
|
): Flow<Artist?>
|
||||||
|
|
||||||
// A credited track artist may not have an artists row; its name only lives on the songs.
|
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
|
||||||
|
fun artistBio(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<ArtistBio?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
|
||||||
|
suspend fun artistBioOnce(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): ArtistBio?
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertArtistBio(item: ArtistBio)
|
||||||
|
|
||||||
|
// A credited track artist may not have an artists row; its name lives on song_artists or the songs.
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
|
"SELECT COALESCE(" +
|
||||||
"AND artist IS NOT NULL AND artist != '' ORDER BY id LIMIT 1",
|
"(SELECT name FROM song_artists WHERE sourceId = :sourceId AND artistId = :artistId " +
|
||||||
|
"AND name != '' LIMIT 1), " +
|
||||||
|
"(SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
|
||||||
|
"AND artist IS NOT NULL AND artist != '' LIMIT 1))",
|
||||||
)
|
)
|
||||||
fun artistName(
|
fun artistName(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -970,16 +1276,18 @@ interface LibraryDao {
|
|||||||
songId: String,
|
songId: String,
|
||||||
): Flow<List<String>>
|
): Flow<List<String>>
|
||||||
|
|
||||||
@Query(
|
@Query(ALBUMS_FOR_ARTIST)
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (artistId = :artistId " +
|
|
||||||
"OR id IN (SELECT albumId FROM songs WHERE sourceId = :sourceId AND artistId = :artistId)) " +
|
|
||||||
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
|
||||||
)
|
|
||||||
fun albumsForArtist(
|
fun albumsForArtist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<List<Album>>
|
): Flow<List<Album>>
|
||||||
|
|
||||||
|
@Query(ALBUM_CREDIT_ALBUM_IDS)
|
||||||
|
fun albumCreditAlbumIds(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<List<String>>
|
||||||
|
|
||||||
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
|
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
|
||||||
fun discs(
|
fun discs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -1015,15 +1323,33 @@ interface LibraryDao {
|
|||||||
playlistId: String,
|
playlistId: String,
|
||||||
): PagingSource<Int, PlaylistSongItem>
|
): PagingSource<Int, PlaylistSongItem>
|
||||||
|
|
||||||
|
// Download visibility follows the credit path: see DOWNLOADED_ARTIST_ALBUMS.
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND (" +
|
"SELECT * FROM (" +
|
||||||
"(artistId = :artistId AND EXISTS (SELECT 1 FROM songs " +
|
"SELECT albums.* FROM albums " +
|
||||||
|
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM songs " +
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')) " +
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
"OR id IN (SELECT songs.albumId FROM songs " +
|
"UNION " +
|
||||||
|
"SELECT albums.* FROM album_artists " +
|
||||||
|
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||||
|
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM songs " +
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND sd.status = 'Completed')) " +
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||||
"ORDER BY year DESC, name COLLATE NOCASE, id",
|
"UNION " +
|
||||||
|
"SELECT albums.* FROM song_artists " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
||||||
|
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT albums.* FROM songs " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
|
||||||
|
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
|
||||||
)
|
)
|
||||||
fun albumsForArtistDownloaded(
|
fun albumsForArtistDownloaded(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.sqlite.SQLiteStatement
|
|||||||
import com.subtracks.data.prefs.AlbumSort
|
import com.subtracks.data.prefs.AlbumSort
|
||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
|
|
||||||
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
|
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
|
||||||
// the resulting PagingSource is not invalidated by download writes (including progress updates).
|
// the resulting PagingSource is not invalidated by download writes (including progress updates).
|
||||||
@@ -15,15 +16,19 @@ internal const val ALBUMS_FILTER_BASE =
|
|||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
||||||
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
||||||
"OR instr(lower(albumArtist), lower(?)) > 0))) "
|
"OR instr(lower(albumArtist), lower(?)) > 0 " +
|
||||||
|
"OR instr(lower(sortName), lower(?)) > 0 " +
|
||||||
|
"OR instr(lower(albumArtistSort), lower(?)) > 0))) "
|
||||||
|
|
||||||
internal const val ARTISTS_FILTER_BASE =
|
internal const val ARTISTS_FILTER_BASE =
|
||||||
"FROM artists WHERE sourceId = ? " +
|
"FROM artists WHERE sourceId = ? " +
|
||||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
||||||
|
"AND $ARTIST_CREDITED " +
|
||||||
"AND (? = '' " +
|
"AND (? = '' " +
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||||
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||||
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
|
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
||||||
|
"OR instr(lower(sortName), lower(?)) > 0))) "
|
||||||
|
|
||||||
internal const val PLAYLISTS_FILTER_BASE =
|
internal const val PLAYLISTS_FILTER_BASE =
|
||||||
"FROM playlists WHERE sourceId = ? " +
|
"FROM playlists WHERE sourceId = ? " +
|
||||||
@@ -38,8 +43,9 @@ internal fun albumsDefaultQuery(
|
|||||||
search: String,
|
search: String,
|
||||||
sort: AlbumSort,
|
sort: AlbumSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
): RoomRawQuery {
|
): RoomRawQuery {
|
||||||
val order = albumOrder(sort, descending)
|
val order = albumOrder(sort, descending, sortNameMode)
|
||||||
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||||
}
|
}
|
||||||
@@ -53,10 +59,10 @@ internal fun rediscoverAlbumsQuery(
|
|||||||
): RoomRawQuery =
|
): RoomRawQuery =
|
||||||
RoomRawQuery(
|
RoomRawQuery(
|
||||||
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
|
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
|
||||||
"ORDER BY played ASC, name COLLATE NOCASE, id",
|
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
||||||
) { stmt ->
|
) { stmt ->
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||||
stmt.bindLong(11, cutoff)
|
stmt.bindLong(13, cutoff)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun SQLiteStatement.bindAlbumFilters(
|
private fun SQLiteStatement.bindAlbumFilters(
|
||||||
@@ -74,6 +80,8 @@ private fun SQLiteStatement.bindAlbumFilters(
|
|||||||
bindText(8, search)
|
bindText(8, search)
|
||||||
bindText(9, search)
|
bindText(9, search)
|
||||||
bindText(10, search)
|
bindText(10, search)
|
||||||
|
bindText(11, search)
|
||||||
|
bindText(12, search)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun artistsDefaultQuery(
|
internal fun artistsDefaultQuery(
|
||||||
@@ -82,9 +90,10 @@ internal fun artistsDefaultQuery(
|
|||||||
search: String,
|
search: String,
|
||||||
sort: ArtistSort,
|
sort: ArtistSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
): RoomRawQuery {
|
): RoomRawQuery {
|
||||||
val order = artistOrder(sort, descending)
|
val order = artistOrder(sort, descending, sortNameMode)
|
||||||
return RoomRawQuery("SELECT * $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||||
stmt.bindLong(1, sourceId)
|
stmt.bindLong(1, sourceId)
|
||||||
stmt.bindInt(2, starredFilter)
|
stmt.bindInt(2, starredFilter)
|
||||||
stmt.bindInt(3, starredFilter)
|
stmt.bindInt(3, starredFilter)
|
||||||
@@ -94,6 +103,7 @@ internal fun artistsDefaultQuery(
|
|||||||
stmt.bindText(7, search)
|
stmt.bindText(7, search)
|
||||||
stmt.bindText(8, search)
|
stmt.bindText(8, search)
|
||||||
stmt.bindText(9, search)
|
stmt.bindText(9, search)
|
||||||
|
stmt.bindText(10, search)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,27 +124,82 @@ internal fun playlistsDefaultQuery(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun albumNameSortKey(mode: SortNameMode): String =
|
||||||
|
when (mode) {
|
||||||
|
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||||
|
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||||
|
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun albumArtistSortKey(mode: SortNameMode): String =
|
||||||
|
when (mode) {
|
||||||
|
SortNameMode.SortName -> {
|
||||||
|
"albumArtistSort COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
|
||||||
|
SortNameMode.Name -> {
|
||||||
|
"albumArtist COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
|
||||||
|
SortNameMode.NonLatin -> {
|
||||||
|
"(CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun artistNameSortKey(mode: SortNameMode): String =
|
||||||
|
when (mode) {
|
||||||
|
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||||
|
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||||
|
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||||
|
}
|
||||||
|
|
||||||
private fun albumOrder(
|
private fun albumOrder(
|
||||||
sort: AlbumSort,
|
sort: AlbumSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
mode: SortNameMode,
|
||||||
): String =
|
): String =
|
||||||
when (sort) {
|
when (sort) {
|
||||||
AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME
|
AlbumSort.Name -> {
|
||||||
AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST
|
val name = albumNameSortKey(mode)
|
||||||
AlbumSort.Year -> if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||||
AlbumSort.Added -> if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
}
|
||||||
AlbumSort.Starred -> if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
|
||||||
AlbumSort.Frequent -> if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
AlbumSort.Artist -> {
|
||||||
AlbumSort.Recent -> if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
val artist = albumArtistSortKey(mode)
|
||||||
|
val name = albumNameSortKey(mode)
|
||||||
|
if (descending) "$artist DESC, year DESC, $name DESC, id DESC" else "$artist, year, $name, id"
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Year -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Added -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Starred -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Frequent -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
||||||
|
}
|
||||||
|
|
||||||
|
AlbumSort.Recent -> {
|
||||||
|
if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun artistOrder(
|
private fun artistOrder(
|
||||||
sort: ArtistSort,
|
sort: ArtistSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
mode: SortNameMode,
|
||||||
): String =
|
): String =
|
||||||
when (sort) {
|
when (sort) {
|
||||||
ArtistSort.Name -> {
|
ArtistSort.Name -> {
|
||||||
if (descending) ARTIST_ORDER_BY_NAME_REVERSED else ARTIST_ORDER_BY_NAME
|
val name = artistNameSortKey(mode)
|
||||||
|
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||||
}
|
}
|
||||||
|
|
||||||
ArtistSort.AlbumCount -> {
|
ArtistSort.AlbumCount -> {
|
||||||
|
|||||||
@@ -384,6 +384,285 @@ val MIGRATION_26_27 =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_27_28 =
|
||||||
|
object : Migration(27, 28) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackGain` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumGain` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackPeak` REAL")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumPeak` REAL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_28_29 =
|
||||||
|
object : Migration(28, 29) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `artists` TEXT NOT NULL DEFAULT ''")
|
||||||
|
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `artists` TEXT NOT NULL DEFAULT ''")
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `album_artists` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, " +
|
||||||
|
"`position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
|
||||||
|
"PRIMARY KEY(`sourceId`, `albumId`, `position`), " +
|
||||||
|
"FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) " +
|
||||||
|
"ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_album_artists_sourceId_artistId` ON `album_artists` (`sourceId`, `artistId`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `song_artists` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, " +
|
||||||
|
"`position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
|
||||||
|
"PRIMARY KEY(`sourceId`, `songId`, `position`), " +
|
||||||
|
"FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) " +
|
||||||
|
"ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_song_artists_sourceId_artistId` ON `song_artists` (`sourceId`, `artistId`)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_29_30 =
|
||||||
|
object : Migration(29, 30) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `songCount` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
connection.execSQL(
|
||||||
|
"WITH credited AS (" +
|
||||||
|
"SELECT sourceId, artistId, songId FROM song_artists " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT sourceId, artistId, id AS songId FROM songs WHERE artistId IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT album_artists.sourceId, album_artists.artistId, songs.id " +
|
||||||
|
"FROM album_artists JOIN songs " +
|
||||||
|
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT albums.sourceId, albums.artistId, songs.id " +
|
||||||
|
"FROM albums JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
|
||||||
|
"WHERE albums.artistId IS NOT NULL" +
|
||||||
|
"), counts AS (" +
|
||||||
|
"SELECT sourceId, artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY sourceId, artistId" +
|
||||||
|
") " +
|
||||||
|
"UPDATE artists SET songCount = c.songCount " +
|
||||||
|
"FROM (SELECT artists.sourceId AS sourceId, artists.id AS id, " +
|
||||||
|
"COALESCE(counts.songCount, 0) AS songCount " +
|
||||||
|
"FROM artists LEFT JOIN counts " +
|
||||||
|
"ON counts.sourceId = artists.sourceId AND counts.id = artists.id) c " +
|
||||||
|
"WHERE artists.sourceId = c.sourceId AND artists.id = c.id " +
|
||||||
|
"AND artists.songCount != c.songCount",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_30_31 =
|
||||||
|
object : Migration(30, 31) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `albumArtistSort` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `artistSort` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||||
|
dropSearchIndex(connection, "artist_search")
|
||||||
|
dropSearchIndex(connection, "album_search")
|
||||||
|
dropSearchIndex(connection, "song_search")
|
||||||
|
connection.execSQL("UPDATE `artists` SET `sortName` = `name`")
|
||||||
|
connection.execSQL("UPDATE `albums` SET `sortName` = `name`")
|
||||||
|
connection.execSQL("UPDATE `songs` SET `sortName` = `title`")
|
||||||
|
connection.execSQL(
|
||||||
|
"UPDATE `albums` SET `albumArtistSort` = COALESCE(" +
|
||||||
|
"(SELECT a.`sortName` FROM `artists` a " +
|
||||||
|
"WHERE a.`sourceId` = `albums`.`sourceId` AND a.`id` = `albums`.`artistId`), " +
|
||||||
|
"(SELECT a.`sortName` FROM `album_artists` aa JOIN `artists` a " +
|
||||||
|
"ON a.`sourceId` = aa.`sourceId` AND a.`id` = aa.`artistId` " +
|
||||||
|
"WHERE aa.`sourceId` = `albums`.`sourceId` AND aa.`albumId` = `albums`.`id` " +
|
||||||
|
"ORDER BY aa.`position` LIMIT 1), " +
|
||||||
|
"`albumArtist`, '')",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"UPDATE `songs` SET `artistSort` = COALESCE(" +
|
||||||
|
"(SELECT a.`sortName` FROM `artists` a " +
|
||||||
|
"WHERE a.`sourceId` = `songs`.`sourceId` AND a.`id` = `songs`.`artistId`), " +
|
||||||
|
"(SELECT a.`sortName` FROM `song_artists` sa JOIN `artists` a " +
|
||||||
|
"ON a.`sourceId` = sa.`sourceId` AND a.`id` = sa.`artistId` " +
|
||||||
|
"WHERE sa.`sourceId` = `songs`.`sourceId` AND sa.`songId` = `songs`.`id` " +
|
||||||
|
"ORDER BY sa.`position` LIMIT 1), " +
|
||||||
|
"`artist`, '')",
|
||||||
|
)
|
||||||
|
ARTIST_INDEXES.forEach { connection.execSQL("DROP INDEX IF EXISTS `$it`") }
|
||||||
|
ALBUM_INDEXES.forEach { connection.execSQL("DROP INDEX IF EXISTS `$it`") }
|
||||||
|
ARTIST_INDEX_SQL.forEach { connection.execSQL(it) }
|
||||||
|
ALBUM_INDEX_SQL.forEach { connection.execSQL(it) }
|
||||||
|
createSearchIndex(connection, "artist_search", "artists", listOf("name", "sortName"))
|
||||||
|
createSearchIndex(connection, "album_search", "albums", listOf("name", "albumArtist", "sortName", "albumArtistSort"))
|
||||||
|
createSearchIndex(connection, "song_search", "songs", listOf("title", "artist", "sortName", "artistSort"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ARTIST_INDEXES =
|
||||||
|
listOf("index_artists_name", "index_artists_albumCount", "index_artists_starred", "index_artists_frequent", "index_artists_recent")
|
||||||
|
|
||||||
|
private val ALBUM_INDEXES =
|
||||||
|
listOf(
|
||||||
|
"index_albums_name",
|
||||||
|
"index_albums_artist",
|
||||||
|
"index_albums_year",
|
||||||
|
"index_albums_added",
|
||||||
|
"index_albums_starred",
|
||||||
|
"index_albums_frequent",
|
||||||
|
"index_albums_recent",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val ARTIST_INDEX_SQL =
|
||||||
|
listOf(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_name` ON `artists` (`sourceId`, `sortName`, `id`)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `artists` " +
|
||||||
|
"(`sourceId` ASC, `albumCount` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `artists` " +
|
||||||
|
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `artists` " +
|
||||||
|
"(`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `artists` " +
|
||||||
|
"(`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
private val ALBUM_INDEX_SQL =
|
||||||
|
listOf(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_name` ON `albums` (`sourceId`, `sortName`, `id`)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `albums` " +
|
||||||
|
"(`sourceId`, `albumArtistSort`, `year`, `sortName`, `id`)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_year` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `year` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_added` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `created` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `albums` " +
|
||||||
|
"(`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun dropSearchIndex(
|
||||||
|
connection: SQLiteConnection,
|
||||||
|
table: String,
|
||||||
|
) {
|
||||||
|
listOf("BEFORE_UPDATE", "BEFORE_DELETE", "AFTER_UPDATE", "AFTER_INSERT").forEach {
|
||||||
|
connection.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_${table}_$it`")
|
||||||
|
}
|
||||||
|
connection.execSQL("DROP TABLE IF EXISTS `$table`")
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_31_32 =
|
||||||
|
object : Migration(31, 32) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `credited` INTEGER NOT NULL DEFAULT 1")
|
||||||
|
connection.execSQL(
|
||||||
|
"UPDATE `artists` SET `credited` = 0 " +
|
||||||
|
"WHERE EXISTS (SELECT 1 FROM `album_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
|
||||||
|
"AND EXISTS (SELECT 1 FROM `song_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM `album_artists` aa " +
|
||||||
|
"WHERE aa.`sourceId` = `artists`.`sourceId` AND aa.`artistId` = `artists`.`id`) " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM `song_artists` sa " +
|
||||||
|
"WHERE sa.`sourceId` = `artists`.`sourceId` AND sa.`artistId` = `artists`.`id`)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_32_33 =
|
||||||
|
object : Migration(32, 33) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `sources` ADD COLUMN `creditsStale` INTEGER NOT NULL DEFAULT 1")
|
||||||
|
connection.execSQL("DROP INDEX IF EXISTS `index_songs_starred`")
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_songs_starred` ON `songs` " +
|
||||||
|
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recomputes the artist totals from the same credit unions the sync uses, so badges, the artist
|
||||||
|
// page and the shuffle queue agree for libraries that predate the change.
|
||||||
|
val MIGRATION_33_34 =
|
||||||
|
object : Migration(33, 34) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL(
|
||||||
|
"WITH credited_songs AS (" +
|
||||||
|
"SELECT songs.`sourceId` AS sourceId, songs.`artistId` AS artistId, " +
|
||||||
|
"songs.`id` AS songId, songs.`playCount` AS playCount, songs.`played` AS played " +
|
||||||
|
"FROM songs WHERE songs.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT song_artists.`sourceId`, song_artists.`artistId`, songs.`id`, " +
|
||||||
|
"songs.`playCount`, songs.`played` " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = song_artists.`sourceId` AND songs.`id` = song_artists.`songId` " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT albums.`sourceId`, albums.`artistId`, songs.`id`, " +
|
||||||
|
"songs.`playCount`, songs.`played` " +
|
||||||
|
"FROM albums JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = albums.`sourceId` AND songs.`albumId` = albums.`id` " +
|
||||||
|
"WHERE albums.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT album_artists.`sourceId`, album_artists.`artistId`, songs.`id`, " +
|
||||||
|
"songs.`playCount`, songs.`played` " +
|
||||||
|
"FROM album_artists JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = album_artists.`sourceId` AND songs.`albumId` = album_artists.`albumId`" +
|
||||||
|
"), credited_albums AS (" +
|
||||||
|
"SELECT albums.`sourceId` AS sourceId, albums.`artistId` AS artistId, " +
|
||||||
|
"albums.`id` AS albumId " +
|
||||||
|
"FROM albums WHERE albums.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT album_artists.`sourceId`, album_artists.`artistId`, albums.`id` " +
|
||||||
|
"FROM album_artists JOIN albums " +
|
||||||
|
"ON albums.`sourceId` = album_artists.`sourceId` AND albums.`id` = album_artists.`albumId` " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT songs.`sourceId`, songs.`artistId`, songs.`albumId` " +
|
||||||
|
"FROM songs JOIN albums " +
|
||||||
|
"ON albums.`sourceId` = songs.`sourceId` AND albums.`id` = songs.`albumId` " +
|
||||||
|
"WHERE songs.`artistId` IS NOT NULL " +
|
||||||
|
"UNION " +
|
||||||
|
"SELECT song_artists.`sourceId`, song_artists.`artistId`, songs.`albumId` " +
|
||||||
|
"FROM song_artists JOIN songs " +
|
||||||
|
"ON songs.`sourceId` = song_artists.`sourceId` AND songs.`id` = song_artists.`songId` " +
|
||||||
|
"JOIN albums ON albums.`sourceId` = songs.`sourceId` AND albums.`id` = songs.`albumId`" +
|
||||||
|
"), totals AS (" +
|
||||||
|
"SELECT sourceId, artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||||
|
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||||
|
"FROM credited_songs GROUP BY sourceId, artistId" +
|
||||||
|
"), album_totals AS (" +
|
||||||
|
"SELECT sourceId, artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||||
|
"FROM credited_albums GROUP BY sourceId, artistId" +
|
||||||
|
") " +
|
||||||
|
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||||
|
"playCount = c.playCount, played = c.played " +
|
||||||
|
"FROM (SELECT artists.`sourceId` AS sourceId, artists.`id` AS id, " +
|
||||||
|
"COALESCE(totals.songCount, 0) AS songCount, " +
|
||||||
|
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||||
|
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played " +
|
||||||
|
"FROM artists LEFT JOIN totals " +
|
||||||
|
"ON totals.sourceId = artists.`sourceId` AND totals.id = artists.`id` " +
|
||||||
|
"LEFT JOIN album_totals " +
|
||||||
|
"ON album_totals.sourceId = artists.`sourceId` AND album_totals.id = artists.`id` " +
|
||||||
|
"WHERE EXISTS (SELECT 1 FROM album_artists WHERE album_artists.`sourceId` = artists.`sourceId`) " +
|
||||||
|
"OR EXISTS (SELECT 1 FROM song_artists WHERE song_artists.`sourceId` = artists.`sourceId`)" +
|
||||||
|
") c " +
|
||||||
|
"WHERE artists.`sourceId` = c.sourceId AND artists.`id` = c.id " +
|
||||||
|
"AND (artists.`songCount` != c.songCount OR artists.`albumCount` != c.albumCount " +
|
||||||
|
"OR artists.`playCount` != c.playCount OR artists.`played` IS NOT c.played)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_34_35 =
|
||||||
|
object : Migration(34, 35) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `artist_bios` (" +
|
||||||
|
"`sourceId` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `biography` TEXT, " +
|
||||||
|
"PRIMARY KEY(`sourceId`, `artistId`), " +
|
||||||
|
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
||||||
private suspend fun createSearchIndex(
|
private suspend fun createSearchIndex(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
@@ -416,6 +695,80 @@ private suspend fun createSearchIndex(
|
|||||||
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
|
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_35_36 =
|
||||||
|
object : Migration(35, 36) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `artistImageUrl` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `coverArts` TEXT NOT NULL DEFAULT ''")
|
||||||
|
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `downloadedCoverArts` TEXT NOT NULL DEFAULT ''")
|
||||||
|
optimizeArtistSearchTriggers(connection)
|
||||||
|
backfillArtistArt(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_36_37 =
|
||||||
|
object : Migration(36, 37) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_name_plain` ON `albums` (`sourceId`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_albums_artist_plain` " +
|
||||||
|
"ON `albums` (`sourceId`, `albumArtist`, `year`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE INDEX IF NOT EXISTS `index_artists_name_plain` ON `artists` (`sourceId`, `name`, `id`)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val MIGRATION_37_38 =
|
||||||
|
object : Migration(37, 38) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `owner` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `shared` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
|
||||||
|
val triggers = mutableListOf<String>()
|
||||||
|
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
|
||||||
|
statement.bindText(1, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||||
|
statement.bindText(2, "room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||||
|
while (statement.step()) triggers += statement.getText(0)
|
||||||
|
}
|
||||||
|
return triggers.size == 2 && triggers.all { it.contains("UPDATE OF `name`, `sortName`") }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun optimizeArtistSearchTriggers(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||||
|
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE " +
|
||||||
|
"BEFORE UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
|
||||||
|
"DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END",
|
||||||
|
)
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE " +
|
||||||
|
"AFTER UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
|
||||||
|
"INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) " +
|
||||||
|
"VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun backfillArtistArt(connection: SQLiteConnection) {
|
||||||
|
val sourceIds = mutableListOf<Long>()
|
||||||
|
connection.prepare("SELECT id FROM sources").use { statement ->
|
||||||
|
while (statement.step()) sourceIds += statement.getLong(0)
|
||||||
|
}
|
||||||
|
sourceIds.forEach { sourceId ->
|
||||||
|
connection.prepare(ARTIST_ART_UPDATE_SQL).use { statement ->
|
||||||
|
statement.bindLong(1, sourceId)
|
||||||
|
statement.step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val MIGRATIONS: Array<Migration> =
|
val MIGRATIONS: Array<Migration> =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
MIGRATION_1_2,
|
MIGRATION_1_2,
|
||||||
@@ -444,4 +797,15 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_24_25,
|
MIGRATION_24_25,
|
||||||
MIGRATION_25_26,
|
MIGRATION_25_26,
|
||||||
MIGRATION_26_27,
|
MIGRATION_26_27,
|
||||||
|
MIGRATION_27_28,
|
||||||
|
MIGRATION_28_29,
|
||||||
|
MIGRATION_29_30,
|
||||||
|
MIGRATION_30_31,
|
||||||
|
MIGRATION_31_32,
|
||||||
|
MIGRATION_32_33,
|
||||||
|
MIGRATION_33_34,
|
||||||
|
MIGRATION_34_35,
|
||||||
|
MIGRATION_35_36,
|
||||||
|
MIGRATION_36_37,
|
||||||
|
MIGRATION_37_38,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package com.subtracks.data.db
|
|||||||
|
|
||||||
import androidx.room3.Dao
|
import androidx.room3.Dao
|
||||||
import androidx.room3.Insert
|
import androidx.room3.Insert
|
||||||
|
import androidx.room3.OnConflictStrategy
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
import androidx.room3.Upsert
|
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
@@ -19,13 +19,16 @@ private const val ALBUM_SONGS_ORDER = " ORDER BY songs.disc, songs.track, songs.
|
|||||||
|
|
||||||
private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
|
private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
|
||||||
|
|
||||||
|
private const val ARTIST_SONG_IDS =
|
||||||
|
"(SELECT songId FROM ($CREDITED_SONG_ROWS) WHERE artistId = :artistId)"
|
||||||
|
|
||||||
private const val ARTIST_SONGS_SELECT =
|
private const val ARTIST_SONGS_SELECT =
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)"
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
|
||||||
|
|
||||||
private const val ARTIST_SONGS_ORDER =
|
private const val ARTIST_SONGS_ORDER =
|
||||||
" ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
" ORDER BY albums.id IS NULL, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
||||||
|
|
||||||
private const val SONG_SQL =
|
private const val SONG_SQL =
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
@@ -55,7 +58,7 @@ interface QueueDao {
|
|||||||
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
||||||
suspend fun cursor(): PlaybackCursor?
|
suspend fun cursor(): PlaybackCursor?
|
||||||
|
|
||||||
@Upsert
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun setCursor(cursor: PlaybackCursor)
|
suspend fun setCursor(cursor: PlaybackCursor)
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@@ -82,8 +85,8 @@ interface QueueDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT COUNT(*) FROM songs " +
|
"SELECT COUNT(*) FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)",
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS",
|
||||||
)
|
)
|
||||||
suspend fun artistLength(
|
suspend fun artistLength(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -190,8 +193,8 @@ interface QueueDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM songs " +
|
"SELECT songs.id FROM songs " +
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)" +
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS" +
|
||||||
ARTIST_SONGS_ORDER,
|
ARTIST_SONGS_ORDER,
|
||||||
)
|
)
|
||||||
suspend fun artistSongIds(
|
suspend fun artistSongIds(
|
||||||
@@ -231,7 +234,7 @@ interface QueueDao {
|
|||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||||
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
"ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||||
)
|
)
|
||||||
suspend fun genreSongs(
|
suspend fun genreSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -244,7 +247,7 @@ interface QueueDao {
|
|||||||
"SELECT songs.id FROM song_genres " +
|
"SELECT songs.id FROM song_genres " +
|
||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||||
"ORDER BY songs.title COLLATE NOCASE, songs.id",
|
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
|
||||||
)
|
)
|
||||||
suspend fun genreSongIds(
|
suspend fun genreSongIds(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -261,7 +264,7 @@ interface QueueDao {
|
|||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
||||||
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||||
)
|
)
|
||||||
suspend fun starredSongs(
|
suspend fun starredSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -271,7 +274,7 @@ interface QueueDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
||||||
"ORDER BY starred DESC, title COLLATE NOCASE, id",
|
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
|
||||||
)
|
)
|
||||||
suspend fun starredSongIds(sourceId: Long): List<String>
|
suspend fun starredSongIds(sourceId: Long): List<String>
|
||||||
|
|
||||||
@@ -280,7 +283,7 @@ interface QueueDao {
|
|||||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||||
)
|
)
|
||||||
suspend fun downloadedSongs(
|
suspend fun downloadedSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -292,7 +295,7 @@ interface QueueDao {
|
|||||||
"SELECT songs.id FROM song_downloads d " +
|
"SELECT songs.id FROM song_downloads d " +
|
||||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
|
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||||
)
|
)
|
||||||
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,22 @@ interface SourcesDao {
|
|||||||
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
|
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
|
||||||
suspend fun activeSourceIdOnce(): Long?
|
suspend fun activeSourceIdOnce(): Long?
|
||||||
|
|
||||||
|
@Query("SELECT creditsStale FROM sources WHERE id = :sourceId")
|
||||||
|
suspend fun creditsStale(sourceId: Long): Boolean?
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET creditsStale = :stale WHERE id = :sourceId AND creditsStale != :stale")
|
||||||
|
suspend fun setCreditsStale(
|
||||||
|
sourceId: Long,
|
||||||
|
stale: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Query("UPDATE sources SET name = :name, address = :address WHERE id = :sourceId")
|
||||||
|
suspend fun updateSourceDetails(
|
||||||
|
sourceId: Long,
|
||||||
|
name: String,
|
||||||
|
address: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Insert
|
@Insert
|
||||||
suspend fun insertSource(source: Source): Long
|
suspend fun insertSource(source: Source): Long
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import androidx.room3.ColumnTypeConverters
|
|||||||
import androidx.room3.Database
|
import androidx.room3.Database
|
||||||
import androidx.room3.RoomDatabase
|
import androidx.room3.RoomDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.AlbumArtistRef
|
||||||
import com.subtracks.data.model.AlbumSearch
|
import com.subtracks.data.model.AlbumSearch
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
|
import com.subtracks.data.model.ArtistRefsConverter
|
||||||
import com.subtracks.data.model.ArtistSearch
|
import com.subtracks.data.model.ArtistSearch
|
||||||
import com.subtracks.data.model.ArtworkSeed
|
import com.subtracks.data.model.ArtworkSeed
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
@@ -20,10 +23,12 @@ import com.subtracks.data.model.PlaylistSong
|
|||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
import com.subtracks.data.model.QueueKindConverter
|
import com.subtracks.data.model.QueueKindConverter
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.SongArtistRef
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.SongGenre
|
import com.subtracks.data.model.SongGenre
|
||||||
import com.subtracks.data.model.SongSearch
|
import com.subtracks.data.model.SongSearch
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.model.StringListConverter
|
||||||
import com.subtracks.data.model.SubsonicSource
|
import com.subtracks.data.model.SubsonicSource
|
||||||
import com.subtracks.data.model.UpNextEntry
|
import com.subtracks.data.model.UpNextEntry
|
||||||
|
|
||||||
@@ -32,15 +37,18 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
Source::class,
|
Source::class,
|
||||||
SubsonicSource::class,
|
SubsonicSource::class,
|
||||||
Artist::class,
|
Artist::class,
|
||||||
|
ArtistBio::class,
|
||||||
ArtistSearch::class,
|
ArtistSearch::class,
|
||||||
Album::class,
|
Album::class,
|
||||||
AlbumSearch::class,
|
AlbumSearch::class,
|
||||||
Disc::class,
|
Disc::class,
|
||||||
|
AlbumArtistRef::class,
|
||||||
Playlist::class,
|
Playlist::class,
|
||||||
PlaylistSearch::class,
|
PlaylistSearch::class,
|
||||||
PlaylistSong::class,
|
PlaylistSong::class,
|
||||||
Song::class,
|
Song::class,
|
||||||
SongGenre::class,
|
SongGenre::class,
|
||||||
|
SongArtistRef::class,
|
||||||
SongSearch::class,
|
SongSearch::class,
|
||||||
QueueEntry::class,
|
QueueEntry::class,
|
||||||
UpNextEntry::class,
|
UpNextEntry::class,
|
||||||
@@ -49,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
SongDownload::class,
|
SongDownload::class,
|
||||||
PendingAction::class,
|
PendingAction::class,
|
||||||
],
|
],
|
||||||
version = 27,
|
version = 38,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(
|
@ColumnTypeConverters(
|
||||||
@@ -57,6 +65,8 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
DownloadStatusConverter::class,
|
DownloadStatusConverter::class,
|
||||||
DownloadErrorConverter::class,
|
DownloadErrorConverter::class,
|
||||||
PendingActionKindConverter::class,
|
PendingActionKindConverter::class,
|
||||||
|
ArtistRefsConverter::class,
|
||||||
|
StringListConverter::class,
|
||||||
)
|
)
|
||||||
abstract class SubtracksDatabase : RoomDatabase() {
|
abstract class SubtracksDatabase : RoomDatabase() {
|
||||||
abstract fun libraryDao(): LibraryDao
|
abstract fun libraryDao(): LibraryDao
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import com.subtracks.data.db.rediscoverAlbumsQuery
|
|||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistAlbums
|
||||||
|
import com.subtracks.data.model.ArtistBio
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
@@ -22,24 +24,32 @@ import com.subtracks.data.prefs.AlbumSort
|
|||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class LibraryRepository(
|
class LibraryRepository(
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val serverActions: ServerActionSink,
|
private val serverActions: ServerActionSink,
|
||||||
|
private val userPreferences: UserPreferences,
|
||||||
private val showMessage: (UiMessage) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
) {
|
) {
|
||||||
@@ -106,12 +116,18 @@ class LibraryRepository(
|
|||||||
fun searchAlbums(
|
fun searchAlbums(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<Album>> = db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<Album>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun searchArtists(
|
fun searchArtists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<Artist>> = db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<Artist>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun searchPlaylists(
|
fun searchPlaylists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -121,7 +137,10 @@ class LibraryRepository(
|
|||||||
fun searchSongs(
|
fun searchSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
query: String,
|
query: String,
|
||||||
): Flow<List<AlbumSongItem>> = db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT)
|
): Flow<List<AlbumSongItem>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||||
|
}
|
||||||
|
|
||||||
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
||||||
|
|
||||||
@@ -135,9 +154,9 @@ class LibraryRepository(
|
|||||||
suspend fun genreSongOrdinal(
|
suspend fun genreSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
genre: String,
|
genre: String,
|
||||||
title: String,
|
sortName: String,
|
||||||
id: String,
|
id: String,
|
||||||
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, title, id)
|
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, sortName, id)
|
||||||
|
|
||||||
suspend fun downloadedSongOrdinal(
|
suspend fun downloadedSongOrdinal(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -173,26 +192,27 @@ class LibraryRepository(
|
|||||||
search: String,
|
search: String,
|
||||||
downloaded: Boolean,
|
downloaded: Boolean,
|
||||||
): Flow<PagingData<Album>> =
|
): Flow<PagingData<Album>> =
|
||||||
pager(40) {
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
|
pager(40, enablePlaceholders = true) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
if (!downloaded) {
|
if (!downloaded) {
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
||||||
} else {
|
} else {
|
||||||
val downloadedFilter = 1
|
val downloadedFilter = 1
|
||||||
when (sort) {
|
when (sort) {
|
||||||
AlbumSort.Name -> {
|
AlbumSort.Name -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
} else {
|
} else {
|
||||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AlbumSort.Artist -> {
|
AlbumSort.Artist -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
} else {
|
} else {
|
||||||
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +258,7 @@ class LibraryRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun artists(
|
fun artists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -247,18 +268,19 @@ class LibraryRepository(
|
|||||||
search: String,
|
search: String,
|
||||||
downloaded: Boolean,
|
downloaded: Boolean,
|
||||||
): Flow<PagingData<Artist>> =
|
): Flow<PagingData<Artist>> =
|
||||||
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
pager(60) {
|
pager(60) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
if (!downloaded) {
|
if (!downloaded) {
|
||||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
||||||
} else {
|
} else {
|
||||||
val downloadedFilter = 1
|
val downloadedFilter = 1
|
||||||
when (sort) {
|
when (sort) {
|
||||||
ArtistSort.Name -> {
|
ArtistSort.Name -> {
|
||||||
if (descending) {
|
if (descending) {
|
||||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
} else {
|
} else {
|
||||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +318,7 @@ class LibraryRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun playlists(
|
fun playlists(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -353,6 +376,36 @@ class LibraryRepository(
|
|||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<String?> = db.libraryDao().artistName(sourceId, artistId)
|
): Flow<String?> = db.libraryDao().artistName(sourceId, artistId)
|
||||||
|
|
||||||
|
fun artistBio(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<ArtistBio?> =
|
||||||
|
db
|
||||||
|
.libraryDao()
|
||||||
|
.artistBio(sourceId, artistId)
|
||||||
|
.map { bio -> bio?.copy(biography = normalizeBiography(bio.biography)) }
|
||||||
|
|
||||||
|
suspend fun refreshArtistBio(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Result<Unit> =
|
||||||
|
try {
|
||||||
|
val fetched = normalizeBiography(sourceRepository.artistBiography(sourceId, artistId))
|
||||||
|
val current = db.libraryDao().artistBioOnce(sourceId, artistId)
|
||||||
|
// An empty response can mean the metadata provider is temporarily down, so keep the
|
||||||
|
// cached biography instead of erasing it.
|
||||||
|
val biography = fetched ?: current?.biography
|
||||||
|
if (current == null || current.biography != biography) {
|
||||||
|
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, artistId, biography))
|
||||||
|
}
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
Log.w("artist", "failed to refresh bio for $artistId", failure)
|
||||||
|
Result.failure(failure)
|
||||||
|
}
|
||||||
|
|
||||||
fun song(
|
fun song(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
songId: String,
|
||||||
@@ -375,12 +428,24 @@ class LibraryRepository(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
downloaded: Boolean = false,
|
downloaded: Boolean = false,
|
||||||
): Flow<List<Album>> =
|
): Flow<ArtistAlbums> {
|
||||||
|
val albums =
|
||||||
if (downloaded) {
|
if (downloaded) {
|
||||||
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
|
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
|
||||||
} else {
|
} else {
|
||||||
db.libraryDao().albumsForArtist(sourceId, artistId)
|
db.libraryDao().albumsForArtist(sourceId, artistId)
|
||||||
}
|
}
|
||||||
|
return combine(albums, db.libraryDao().albumCreditAlbumIds(sourceId, artistId)) { list, creditIds ->
|
||||||
|
val credits = creditIds.toHashSet()
|
||||||
|
val (albumCredits, appearances) = list.partition { it.id in credits }
|
||||||
|
ArtistAlbums(albumCredits = albumCredits, appearances = appearances)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun artistCoverArts(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Flow<List<String>> = db.libraryDao().artistCoverArts(sourceId, artistId)
|
||||||
|
|
||||||
fun albumDiscs(
|
fun albumDiscs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -464,14 +529,17 @@ class LibraryRepository(
|
|||||||
}
|
}
|
||||||
val previous = starredValue(sourceId, type, id)
|
val previous = starredValue(sourceId, type, id)
|
||||||
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
|
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
|
||||||
|
val action = if (starred) "star" else "unstar"
|
||||||
return try {
|
return try {
|
||||||
serverActions.setStar(type, id, starred)
|
serverActions.setStar(type, id, starred)
|
||||||
|
Log.i("star", "$action ${type.name.lowercase()} $id")
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
|
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
|
||||||
showMessage(UiMessage(R.string.star_update_failed))
|
showMessage(UiMessage(R.string.star_update_failed))
|
||||||
|
Log.w("star", "failed to $action ${type.name.lowercase()} $id", failure)
|
||||||
Result.failure(failure)
|
Result.failure(failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -503,10 +571,11 @@ class LibraryRepository(
|
|||||||
|
|
||||||
private fun <T : Any> pager(
|
private fun <T : Any> pager(
|
||||||
pageSize: Int,
|
pageSize: Int,
|
||||||
|
enablePlaceholders: Boolean = false,
|
||||||
source: () -> PagingSource<Int, T>,
|
source: () -> PagingSource<Int, T>,
|
||||||
): Flow<PagingData<T>> =
|
): Flow<PagingData<T>> =
|
||||||
Pager(
|
Pager(
|
||||||
config = PagingConfig(pageSize = pageSize, enablePlaceholders = false),
|
config = PagingConfig(pageSize = pageSize, enablePlaceholders = enablePlaceholders),
|
||||||
pagingSourceFactory = source,
|
pagingSourceFactory = source,
|
||||||
).flow
|
).flow
|
||||||
}
|
}
|
||||||
@@ -515,4 +584,12 @@ const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000
|
|||||||
|
|
||||||
const val SEARCH_RESULT_LIMIT = 50
|
const val SEARCH_RESULT_LIMIT = 50
|
||||||
|
|
||||||
|
internal fun normalizeBiography(value: String?): String? =
|
||||||
|
value
|
||||||
|
?.replace("\r\n", "\n")
|
||||||
|
?.replace('\r', '\n')
|
||||||
|
?.replace(Regex(" {3,}"), "\n\n")
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
|
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.subtracks.data.model.PendingActionKind
|
|||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.data.source.subsonic.SubsonicException
|
import com.subtracks.data.source.subsonic.SubsonicException
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -29,8 +30,13 @@ class PendingServerActionSink(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
var lastReady: Boolean? = null
|
||||||
combine(offlinePreferences, networkAvailable) { isOffline, online -> !isOffline && online }
|
combine(offlinePreferences, networkAvailable) { isOffline, online -> !isOffline && online }
|
||||||
.collect { ready ->
|
.collect { ready ->
|
||||||
|
if (ready && lastReady == false) {
|
||||||
|
Log.i("net", "connection available, replaying pending server actions")
|
||||||
|
}
|
||||||
|
lastReady = ready
|
||||||
// The persisted flag and connectivity are authoritative, unlike the process
|
// The persisted flag and connectivity are authoritative, unlike the process
|
||||||
// flag's default at cold start, so wait for that flag to agree before replaying.
|
// flag's default at cold start, so wait for that flag to agree before replaying.
|
||||||
if (ready) {
|
if (ready) {
|
||||||
@@ -96,12 +102,14 @@ class PendingServerActionSink(
|
|||||||
for (action in dao.pending(sourceId)) {
|
for (action in dao.pending(sourceId)) {
|
||||||
try {
|
try {
|
||||||
deliver(action)
|
deliver(action)
|
||||||
|
Log.i("net", "replayed ${action.kind.name.lowercase()} ${action.targetId}")
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
// Still unreachable: keep this and the remaining actions for the next attempt.
|
// Still unreachable: keep this and the remaining actions for the next attempt.
|
||||||
if (isRetryable(failure)) return
|
if (isRetryable(failure)) return
|
||||||
// The server answered and refused; drop the action rather than block the queue.
|
// The server answered and refused; drop the action rather than block the queue.
|
||||||
|
Log.w("net", "dropped ${action.kind.name.lowercase()} ${action.targetId}", failure)
|
||||||
}
|
}
|
||||||
dao.delete(action.id)
|
dao.delete(action.id)
|
||||||
}
|
}
|
||||||
@@ -139,6 +147,7 @@ class PendingServerActionSink(
|
|||||||
db.pendingActionDao().replace(
|
db.pendingActionDao().replace(
|
||||||
PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time),
|
PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time),
|
||||||
)
|
)
|
||||||
|
Log.i("net", "queued ${kind.name.lowercase()} $targetId")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ data class QueueSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun locateContext(contextPlay: Long): Pair<QueueEntry, Long>? = locateIn(entries, flatContext(contextPlay) ?: return null)
|
fun locateContext(contextPlay: Long): Pair<QueueEntry, Long>? = locateIn(entries, flatContext(contextPlay) ?: return null)
|
||||||
|
|
||||||
|
fun isAlbumContext(position: Long): Boolean = locate(position)?.first?.kind == QueueKind.Album
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun entryIndexAfter(
|
private fun entryIndexAfter(
|
||||||
@@ -295,8 +297,8 @@ class QueueRepository(
|
|||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// An explicit song entry in the middle of a queue is a reordered context track, so
|
// A song entry can be a single or a track reordered out of a playlist, artist or
|
||||||
// label it by its album rather than losing the context name.
|
// genre, so label it by its album rather than losing the context name.
|
||||||
QueueKind.Song -> {
|
QueueKind.Song -> {
|
||||||
library
|
library
|
||||||
.song(sourceId, refId)
|
.song(sourceId, refId)
|
||||||
@@ -581,9 +583,9 @@ class QueueRepository(
|
|||||||
val toUpNext = snapshot.isUpNext(to)
|
val toUpNext = snapshot.isUpNext(to)
|
||||||
if (fromUpNext && toUpNext) {
|
if (fromUpNext && toUpNext) {
|
||||||
val song = itemAt(snapshot, from) ?: return false
|
val song = itemAt(snapshot, from) ?: return false
|
||||||
val sourceId = snapshot.locate(from)?.first?.sourceId ?: return false
|
val located = snapshot.locate(from) ?: return false
|
||||||
val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
|
val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
|
||||||
val moved = ResolvedQueueEntry(songEntry(sourceId, song.song.id), 1L)
|
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
|
||||||
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
|
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -597,7 +599,7 @@ class QueueRepository(
|
|||||||
val located = snapshot.locateContext(playFrom) ?: return false
|
val located = snapshot.locateContext(playFrom) ?: return false
|
||||||
val song = itemAt(snapshot, from) ?: return false
|
val song = itemAt(snapshot, from) ?: return false
|
||||||
val removed = removeEntry(snapshot.entries, playFrom)
|
val removed = removeEntry(snapshot.entries, playFrom)
|
||||||
val moved = ResolvedQueueEntry(songEntry(located.first.sourceId, song.song.id), 1L)
|
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
|
||||||
val insertAt = playTo.coerceIn(0, removed.sumOf { it.length })
|
val insertAt = playTo.coerceIn(0, removed.sumOf { it.length })
|
||||||
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
|
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
|
||||||
adjustAnchorOnMove(playFrom, insertAt)
|
adjustAnchorOnMove(playFrom, insertAt)
|
||||||
@@ -929,6 +931,18 @@ class QueueRepository(
|
|||||||
return count?.coerceAtMost(available) ?: available
|
return count?.coerceAtMost(available) ?: available
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun movedEntry(
|
||||||
|
source: QueueEntry,
|
||||||
|
songId: String,
|
||||||
|
offsetInEntry: Long,
|
||||||
|
): QueueEntry =
|
||||||
|
if (source.kind == QueueKind.Album) {
|
||||||
|
val offset = source.offset + offsetInEntry
|
||||||
|
source.range(offset, offset)
|
||||||
|
} else {
|
||||||
|
songEntry(source.sourceId, songId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun QueueEntry.range(
|
private fun QueueEntry.range(
|
||||||
start: Long,
|
start: Long,
|
||||||
end: Long,
|
end: Long,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef
|
|||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.model.SubsonicConfig
|
import com.subtracks.data.model.SubsonicConfig
|
||||||
import com.subtracks.data.model.SubsonicSource
|
import com.subtracks.data.model.SubsonicSource
|
||||||
|
import com.subtracks.data.model.artistImageKey
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
@@ -17,6 +18,7 @@ import com.subtracks.data.source.MusicSource
|
|||||||
import com.subtracks.data.source.declaredStreamLength
|
import com.subtracks.data.source.declaredStreamLength
|
||||||
import com.subtracks.data.source.streamLengthSuffix
|
import com.subtracks.data.source.streamLengthSuffix
|
||||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -46,6 +48,13 @@ class SourceRepository(
|
|||||||
val source: SubsonicMusicSource,
|
val source: SubsonicMusicSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ResolvedSource(
|
||||||
|
val config: SubsonicConfig?,
|
||||||
|
val quality: StreamQuality,
|
||||||
|
val mode: NetworkMode,
|
||||||
|
val concurrency: Int,
|
||||||
|
)
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var active: ActiveSource? = null
|
private var active: ActiveSource? = null
|
||||||
|
|
||||||
@@ -61,9 +70,13 @@ class SourceRepository(
|
|||||||
private val _offline = MutableStateFlow(false)
|
private val _offline = MutableStateFlow(false)
|
||||||
val offline: StateFlow<Boolean> = _offline
|
val offline: StateFlow<Boolean> = _offline
|
||||||
|
|
||||||
|
private val _activeSource = MutableStateFlow<Long?>(null)
|
||||||
|
val activeSource: StateFlow<Long?> = _activeSource
|
||||||
|
|
||||||
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
||||||
|
|
||||||
fun setOfflineMode(enabled: Boolean) {
|
fun setOfflineMode(enabled: Boolean) {
|
||||||
|
Log.i("net", "offline mode -> $enabled")
|
||||||
scope.launch { prefs.setOfflineMode(enabled) }
|
scope.launch { prefs.setOfflineMode(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +94,7 @@ class SourceRepository(
|
|||||||
// Read the persisted flag before the source config can build a source or probe it, so a
|
// Read the persisted flag before the source config can build a source or probe it, so a
|
||||||
// cold start with offline on never touches the network.
|
// cold start with offline on never touches the network.
|
||||||
_offline.value = prefs.offlineMode().first()
|
_offline.value = prefs.offlineMode().first()
|
||||||
|
var lastMode: NetworkMode? = null
|
||||||
combine(
|
combine(
|
||||||
db.sourcesDao().activeSubsonicConfig(),
|
db.sourcesDao().activeSubsonicConfig(),
|
||||||
networkMode,
|
networkMode,
|
||||||
@@ -88,13 +102,24 @@ class SourceRepository(
|
|||||||
prefs.streamQuality(NetworkMode.Mobile),
|
prefs.streamQuality(NetworkMode.Mobile),
|
||||||
prefs.syncConcurrency(),
|
prefs.syncConcurrency(),
|
||||||
) { config, mode, wifi, mobile, concurrency ->
|
) { config, mode, wifi, mobile, concurrency ->
|
||||||
Triple(config, if (mode == NetworkMode.Wifi) wifi else mobile, concurrency)
|
ResolvedSource(config, if (mode == NetworkMode.Wifi) wifi else mobile, mode, concurrency)
|
||||||
}.collect { (config, quality, concurrency) ->
|
}.collect { (config, quality, mode, concurrency) ->
|
||||||
|
if (mode != lastMode) {
|
||||||
|
Log.i("net", "network mode -> ${mode.key}")
|
||||||
|
lastMode = mode
|
||||||
|
}
|
||||||
val sourceChanged = config?.id != active?.id
|
val sourceChanged = config?.id != active?.id
|
||||||
|
if (sourceChanged) {
|
||||||
|
Log.i("net", "active source -> ${config?.name ?: "none"}")
|
||||||
|
}
|
||||||
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
||||||
|
_activeSource.value = config?.id
|
||||||
_quality.value = quality
|
_quality.value = quality
|
||||||
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
||||||
scope.launch { runCatching { config.toClient().check("ping") } }
|
scope.launch {
|
||||||
|
runCatching { config.toClient().check("ping") }
|
||||||
|
.onSuccess { Log.i("net", "connected (ping ok)") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +148,18 @@ class SourceRepository(
|
|||||||
return CoverArtRef(url = url, cacheKey = cacheKey)
|
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun artistImage(
|
||||||
|
artistId: String,
|
||||||
|
url: String?,
|
||||||
|
): CoverArtRef? {
|
||||||
|
val current = active ?: return null
|
||||||
|
if (url.isNullOrBlank()) return null
|
||||||
|
val cacheKey = artistImageKey(current.id, artistId)
|
||||||
|
artworkStore.uri(current.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
|
||||||
|
if (_offline.value) return null
|
||||||
|
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
fun networkCoverArt(
|
fun networkCoverArt(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
coverArt: String,
|
coverArt: String,
|
||||||
@@ -200,8 +237,8 @@ class SourceRepository(
|
|||||||
useTokenAuth: Boolean,
|
useTokenAuth: Boolean,
|
||||||
) {
|
) {
|
||||||
val dao = db.sourcesDao()
|
val dao = db.sourcesDao()
|
||||||
val source = dao.sourceOnce(id) ?: return
|
if (dao.sourceOnce(id) == null) return
|
||||||
dao.upsertSource(source.copy(name = name.trim(), address = normalizeAddress(address)))
|
dao.updateSourceDetails(id, name.trim(), normalizeAddress(address))
|
||||||
dao.upsertSubsonicSource(
|
dao.upsertSubsonicSource(
|
||||||
SubsonicSource(
|
SubsonicSource(
|
||||||
sourceId = id,
|
sourceId = id,
|
||||||
@@ -241,6 +278,15 @@ class SourceRepository(
|
|||||||
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
|
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun artistBiography(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): String? {
|
||||||
|
check(!_offline.value) { "Offline mode is on" }
|
||||||
|
val config = db.sourcesDao().subsonicConfigOnce(sourceId) ?: error("No configured source")
|
||||||
|
return config.toMusicSource(quality.value, prefs.syncConcurrency().first()).artistBiography(artistId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun SubsonicConfig.toClient(): SubsonicClient =
|
private fun SubsonicConfig.toClient(): SubsonicClient =
|
||||||
client(
|
client(
|
||||||
address = address,
|
address = address,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ interface MusicSource {
|
|||||||
time: Long? = null,
|
time: Long? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun artistBiography(artistId: String): String? = null
|
||||||
|
|
||||||
fun artists(): Flow<List<Artist>>
|
fun artists(): Flow<List<Artist>>
|
||||||
|
|
||||||
fun albums(): Flow<List<Album>>
|
fun albums(): Flow<List<Album>>
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package com.subtracks.data.source.subsonic
|
|||||||
|
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
|
import com.subtracks.log.Log
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
open class SubsonicException(
|
open class SubsonicException(
|
||||||
val code: Int,
|
val code: Int,
|
||||||
@@ -20,7 +22,6 @@ class SubsonicClient(
|
|||||||
private val password: String,
|
private val password: String,
|
||||||
useTokenAuth: Boolean,
|
useTokenAuth: Boolean,
|
||||||
private val http: OkHttpClient,
|
private val http: OkHttpClient,
|
||||||
private val userAgent: String = "subtracks/android",
|
|
||||||
private val onTokenAuthUnsupported: () -> Unit = {},
|
private val onTokenAuthUnsupported: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -55,30 +56,37 @@ class SubsonicClient(
|
|||||||
fun <T> stream(
|
fun <T> stream(
|
||||||
method: String,
|
method: String,
|
||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
|
timeoutMs: Long? = null,
|
||||||
body: (InputStream) -> T,
|
body: (InputStream) -> T,
|
||||||
): T {
|
): T {
|
||||||
val usedTokenAuth = useTokenAuth
|
val usedTokenAuth = useTokenAuth
|
||||||
return try {
|
return try {
|
||||||
execute(method, params, body)
|
execute(method, params, timeoutMs, body)
|
||||||
} catch (failure: SubsonicException) {
|
} catch (failure: SubsonicException) {
|
||||||
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) throw failure
|
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) {
|
||||||
|
Log.w("http", "$method failed: server code ${failure.code} \"${failure.message}\"")
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
Log.w("http", "$method: token auth unsupported (server code ${failure.code}), retrying with plaintext")
|
||||||
disableTokenAuth()
|
disableTokenAuth()
|
||||||
execute(method, params, body)
|
execute(method, params, timeoutMs, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> execute(
|
private fun <T> execute(
|
||||||
method: String,
|
method: String,
|
||||||
params: Map<String, String>,
|
params: Map<String, String>,
|
||||||
|
timeoutMs: Long?,
|
||||||
body: (InputStream) -> T,
|
body: (InputStream) -> T,
|
||||||
): T {
|
): T {
|
||||||
val request =
|
val request =
|
||||||
Request
|
Request
|
||||||
.Builder()
|
.Builder()
|
||||||
.url(uri(method, params))
|
.url(uri(method, params))
|
||||||
.header("User-Agent", userAgent)
|
|
||||||
.build()
|
.build()
|
||||||
http.newCall(request).execute().use { response ->
|
val call = http.newCall(request)
|
||||||
|
timeoutMs?.let { call.timeout().timeout(it, TimeUnit.MILLISECONDS) }
|
||||||
|
call.execute().use { response ->
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
throw SubsonicException(-1, "HTTP ${response.code}", UiMessage(R.string.error_http, listOf(response.code)))
|
throw SubsonicException(-1, "HTTP ${response.code}", UiMessage(R.string.error_http, listOf(response.code)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import kotlinx.coroutines.flow.flow
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.sync.withPermit
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.xml.sax.Attributes
|
import org.xml.sax.Attributes
|
||||||
@@ -83,6 +85,13 @@ class SubsonicSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun artistBiography(artistId: String): String? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
client.stream("getArtistInfo2", mapOf("id" to artistId), timeoutMs = ARTIST_BIO_TIMEOUT_MS) { input ->
|
||||||
|
SubsonicXml.artistBiography(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun artists(): Flow<List<Artist>> =
|
override fun artists(): Flow<List<Artist>> =
|
||||||
entityBatches(
|
entityBatches(
|
||||||
method = "getArtists",
|
method = "getArtists",
|
||||||
@@ -101,7 +110,7 @@ class SubsonicSource(
|
|||||||
params = page("newest", offset),
|
params = page("newest", offset),
|
||||||
tag = "album",
|
tag = "album",
|
||||||
create = { SubsonicXml.albumDraft(id, it) },
|
create = { SubsonicXml.albumDraft(id, it) },
|
||||||
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
onChild = SubsonicXml::albumChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
raw += batch.size
|
raw += batch.size
|
||||||
val accepted = batch.filter { it.id.isNotEmpty() }
|
val accepted = batch.filter { it.id.isNotEmpty() }
|
||||||
@@ -205,7 +214,7 @@ class SubsonicSource(
|
|||||||
),
|
),
|
||||||
tag = "song",
|
tag = "song",
|
||||||
create = { SubsonicXml.songDraft(id, it) },
|
create = { SubsonicXml.songDraft(id, it) },
|
||||||
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
|
onChild = SubsonicXml::songChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
raw += batch.size
|
raw += batch.size
|
||||||
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
||||||
@@ -224,6 +233,23 @@ class SubsonicSource(
|
|||||||
private fun albumSongs(): Flow<List<Song>> =
|
private fun albumSongs(): Flow<List<Song>> =
|
||||||
channelFlow {
|
channelFlow {
|
||||||
val permits = Semaphore(maxConcurrentFetches)
|
val permits = Semaphore(maxConcurrentFetches)
|
||||||
|
val lock = Mutex()
|
||||||
|
val pending = ArrayList<Song>(PAGE_SIZE)
|
||||||
|
|
||||||
|
suspend fun accept(songs: List<Song>) {
|
||||||
|
lock
|
||||||
|
.withLock {
|
||||||
|
pending.addAll(songs)
|
||||||
|
if (pending.size < PAGE_SIZE) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val batch = ArrayList(pending.subList(0, PAGE_SIZE))
|
||||||
|
pending.subList(0, PAGE_SIZE).clear()
|
||||||
|
batch
|
||||||
|
}
|
||||||
|
}?.let { send(it) }
|
||||||
|
}
|
||||||
|
|
||||||
var offset = 0
|
var offset = 0
|
||||||
var pages = 0
|
var pages = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -248,10 +274,10 @@ class SubsonicSource(
|
|||||||
params = mapOf("id" to albumId),
|
params = mapOf("id" to albumId),
|
||||||
tag = "song",
|
tag = "song",
|
||||||
create = { SubsonicXml.songDraft(id, it) },
|
create = { SubsonicXml.songDraft(id, it) },
|
||||||
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
|
onChild = SubsonicXml::songChild,
|
||||||
).collect { batch ->
|
).collect { batch ->
|
||||||
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
|
||||||
if (accepted.isNotEmpty()) send(accepted)
|
accept(accepted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,7 +291,14 @@ class SubsonicSource(
|
|||||||
if (pages > maxPages) throw pageCapExceeded()
|
if (pages > maxPages) throw pageCapExceeded()
|
||||||
offset += PAGE_SIZE
|
offset += PAGE_SIZE
|
||||||
}
|
}
|
||||||
|
val tail =
|
||||||
|
lock.withLock {
|
||||||
|
val held = pending.toList()
|
||||||
|
pending.clear()
|
||||||
|
held
|
||||||
}
|
}
|
||||||
|
if (tail.isNotEmpty()) send(tail)
|
||||||
|
}.buffer(1)
|
||||||
|
|
||||||
private fun supportsEmptyQuerySearch(): Boolean {
|
private fun supportsEmptyQuerySearch(): Boolean {
|
||||||
emptyQuerySearchSupported?.let { return it }
|
emptyQuerySearchSupported?.let { return it }
|
||||||
@@ -297,7 +330,7 @@ class SubsonicSource(
|
|||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
tag: String,
|
tag: String,
|
||||||
create: (Attributes) -> T,
|
create: (Attributes) -> T,
|
||||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||||
): Flow<List<T>> =
|
): Flow<List<T>> =
|
||||||
channelFlow {
|
channelFlow {
|
||||||
val buffer = ArrayList<T>(PAGE_SIZE)
|
val buffer = ArrayList<T>(PAGE_SIZE)
|
||||||
@@ -339,6 +372,7 @@ class SubsonicSource(
|
|||||||
const val PAGE_SIZE = 500
|
const val PAGE_SIZE = 500
|
||||||
const val MAX_PAGES = 20_000
|
const val MAX_PAGES = 20_000
|
||||||
const val THUMBNAIL_SIZE = 384
|
const val THUMBNAIL_SIZE = 384
|
||||||
|
const val ARTIST_BIO_TIMEOUT_MS = 15_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.subtracks.R
|
|||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
@@ -31,6 +32,8 @@ object SubsonicXml {
|
|||||||
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
||||||
starred = attrs.dateAttr("starred"),
|
starred = attrs.dateAttr("starred"),
|
||||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||||
|
artistImageUrl = attrs.attr("artistImageUrl").ifEmpty { null },
|
||||||
|
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun albumDraft(
|
internal fun albumDraft(
|
||||||
@@ -43,6 +46,8 @@ object SubsonicXml {
|
|||||||
artistId = attrs.attr("artistId").ifEmpty { null },
|
artistId = attrs.attr("artistId").ifEmpty { null },
|
||||||
name = attrs.attr("name"),
|
name = attrs.attr("name"),
|
||||||
albumArtist = attrs.attr("artist").ifEmpty { null },
|
albumArtist = attrs.attr("artist").ifEmpty { null },
|
||||||
|
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
|
||||||
|
sortName = attrs.attr("sortName").ifEmpty { null },
|
||||||
created = attrs.dateAttr("created") ?: 0L,
|
created = attrs.dateAttr("created") ?: 0L,
|
||||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||||
genre = attrs.attr("genre").ifEmpty { null },
|
genre = attrs.attr("genre").ifEmpty { null },
|
||||||
@@ -68,6 +73,8 @@ object SubsonicXml {
|
|||||||
title = attrs.attr("title"),
|
title = attrs.attr("title"),
|
||||||
album = attrs.attr("album").ifEmpty { null },
|
album = attrs.attr("album").ifEmpty { null },
|
||||||
artist = attrs.attr("artist").ifEmpty { null },
|
artist = attrs.attr("artist").ifEmpty { null },
|
||||||
|
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
|
||||||
|
sortName = attrs.attr("sortName").ifEmpty { null },
|
||||||
duration = attrs.longAttr("duration"),
|
duration = attrs.longAttr("duration"),
|
||||||
track = attrs.longAttr("track"),
|
track = attrs.longAttr("track"),
|
||||||
disc = attrs.longAttr("discNumber"),
|
disc = attrs.longAttr("discNumber"),
|
||||||
@@ -78,6 +85,36 @@ object SubsonicXml {
|
|||||||
played = attrs.dateAttr("played"),
|
played = attrs.dateAttr("played"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
internal fun songChild(
|
||||||
|
entity: SongDraft,
|
||||||
|
name: String,
|
||||||
|
attrs: Attributes,
|
||||||
|
level: Int,
|
||||||
|
) {
|
||||||
|
when (name) {
|
||||||
|
"genres" -> if (level == 1) entity.addGenre(attrs)
|
||||||
|
"replayGain" -> if (level == 1) entity.addReplayGain(attrs)
|
||||||
|
"artists" -> if (level == 1) entity.addArtists(attrs)
|
||||||
|
"albumArtists" -> entity.clearNestedArtists()
|
||||||
|
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
||||||
|
else -> if (level == 1) entity.clearNestedArtists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun albumChild(
|
||||||
|
entity: AlbumDraft,
|
||||||
|
name: String,
|
||||||
|
attrs: Attributes,
|
||||||
|
level: Int,
|
||||||
|
) {
|
||||||
|
when (name) {
|
||||||
|
"discTitles" -> if (level == 1) entity.addDiscTitle(attrs)
|
||||||
|
"artists" -> if (level == 1) entity.addArtists(attrs)
|
||||||
|
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
||||||
|
else -> if (level == 1) entity.clearArtists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun playlist(
|
internal fun playlist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
attrs: Attributes,
|
attrs: Attributes,
|
||||||
@@ -92,6 +129,8 @@ object SubsonicXml {
|
|||||||
created = attrs.dateAttr("created") ?: 0L,
|
created = attrs.dateAttr("created") ?: 0L,
|
||||||
changed = attrs.dateAttr("changed") ?: 0L,
|
changed = attrs.dateAttr("changed") ?: 0L,
|
||||||
duration = attrs.longAttr("duration") ?: 0L,
|
duration = attrs.longAttr("duration") ?: 0L,
|
||||||
|
owner = attrs.attr("owner").ifEmpty { null },
|
||||||
|
shared = attrs.boolAttr("public"),
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun playlistSong(
|
internal fun playlistSong(
|
||||||
@@ -115,7 +154,7 @@ object SubsonicXml {
|
|||||||
input: InputStream,
|
input: InputStream,
|
||||||
entityTag: String,
|
entityTag: String,
|
||||||
create: (Attributes) -> T,
|
create: (Attributes) -> T,
|
||||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||||
onEnd: (T) -> Unit,
|
onEnd: (T) -> Unit,
|
||||||
) {
|
) {
|
||||||
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
|
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
|
||||||
@@ -125,10 +164,16 @@ object SubsonicXml {
|
|||||||
input: InputStream,
|
input: InputStream,
|
||||||
entityTag: String,
|
entityTag: String,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val handler = EntityHandler(entityTag, { Unit }, { _, _, _ -> }, { }, stopAfterFirst = true)
|
val handler = EntityHandler(entityTag, { Unit }, { _, _, _, _ -> }, { }, stopAfterFirst = true)
|
||||||
parse(input, handler)
|
parse(input, handler)
|
||||||
return handler.found
|
return handler.found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun artistBiography(input: InputStream): String? {
|
||||||
|
val handler = ArtistBiographyHandler()
|
||||||
|
parse(input, handler)
|
||||||
|
return handler.biography
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class AlbumDraft(
|
internal class AlbumDraft(
|
||||||
@@ -137,6 +182,8 @@ internal class AlbumDraft(
|
|||||||
var artistId: String? = null,
|
var artistId: String? = null,
|
||||||
var name: String = "",
|
var name: String = "",
|
||||||
var albumArtist: String? = null,
|
var albumArtist: String? = null,
|
||||||
|
var displayArtist: String? = null,
|
||||||
|
var sortName: String? = null,
|
||||||
var created: Long = 0L,
|
var created: Long = 0L,
|
||||||
var coverArt: String? = null,
|
var coverArt: String? = null,
|
||||||
var genre: String? = null,
|
var genre: String? = null,
|
||||||
@@ -145,19 +192,41 @@ internal class AlbumDraft(
|
|||||||
var songCount: Long = 0L,
|
var songCount: Long = 0L,
|
||||||
val discTitles: MutableMap<Long, String> = mutableMapOf(),
|
val discTitles: MutableMap<Long, String> = mutableMapOf(),
|
||||||
) {
|
) {
|
||||||
|
private val artists = mutableListOf<ArtistRef>()
|
||||||
|
private var artistContainer = false
|
||||||
|
|
||||||
fun addDiscTitle(attrs: Attributes) {
|
fun addDiscTitle(attrs: Attributes) {
|
||||||
val disc = attrs.longAttr("disc") ?: return
|
val disc = attrs.longAttr("disc") ?: return
|
||||||
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
|
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
|
||||||
discTitles[disc] = title
|
discTitles[disc] = title
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toAlbum(): Album =
|
fun addArtists(attrs: Attributes) {
|
||||||
Album(
|
val ref = attrs.artistRef()
|
||||||
|
if (ref == null) {
|
||||||
|
artistContainer = true
|
||||||
|
} else {
|
||||||
|
artists += ref
|
||||||
|
artistContainer = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addNestedArtist(attrs: Attributes) {
|
||||||
|
if (artistContainer) attrs.artistRef()?.let { artists += it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearArtists() {
|
||||||
|
artistContainer = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toAlbum(): Album {
|
||||||
|
val refs = artistFallback(artists, artistId, albumArtist)
|
||||||
|
return Album(
|
||||||
sourceId = sourceId,
|
sourceId = sourceId,
|
||||||
id = id,
|
id = id,
|
||||||
artistId = artistId,
|
artistId = artistId,
|
||||||
name = name,
|
name = name,
|
||||||
albumArtist = albumArtist,
|
albumArtist = displayName(albumArtist, displayArtist, refs),
|
||||||
created = created,
|
created = created,
|
||||||
coverArt = coverArt,
|
coverArt = coverArt,
|
||||||
genre = genre,
|
genre = genre,
|
||||||
@@ -165,8 +234,11 @@ 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(
|
||||||
private val sourceId: Long,
|
private val sourceId: Long,
|
||||||
@@ -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) {
|
||||||
|
if (!silent) {
|
||||||
val offline = UiMessage(R.string.sync_offline)
|
val offline = UiMessage(R.string.sync_offline)
|
||||||
showMessage(offline)
|
showMessage(offline)
|
||||||
_status.value = SyncStatus.Failed(offline)
|
_status.value = SyncStatus.Failed(offline)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_status.value = SyncStatus.Running
|
_status.value = SyncStatus.Running(silent)
|
||||||
|
runCatching { preferences.setLastSyncAt(System.currentTimeMillis()) }
|
||||||
|
.onFailure { Log.w("sync", "could not record the sync attempt", it) }
|
||||||
|
val startedAt = System.nanoTime()
|
||||||
val result =
|
val result =
|
||||||
try {
|
try {
|
||||||
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
||||||
|
Log.i("sync", "started (source ${source.id})")
|
||||||
serverActions.flush()
|
serverActions.flush()
|
||||||
SyncService(db, source).sync()
|
val summary = SyncService(db, source).sync()
|
||||||
|
Log.i(
|
||||||
|
"sync",
|
||||||
|
"finished in ${elapsedMs(startedAt)}ms: " +
|
||||||
|
"${summary.artists} artists, ${summary.albums} albums, ${summary.songs} songs, " +
|
||||||
|
"${summary.playlists} playlists, ${summary.playlistSongs} playlist tracks, ${summary.pruned} pruned",
|
||||||
|
)
|
||||||
SyncStatus.Success
|
SyncStatus.Success
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
Log.w(TAG, "Sync failed", failure)
|
Log.w("sync", "failed after ${elapsedMs(startedAt)}ms", failure)
|
||||||
val message =
|
val message =
|
||||||
when (failure) {
|
when (failure) {
|
||||||
is NoServerException -> {
|
is NoServerException -> {
|
||||||
@@ -80,16 +109,14 @@ class SyncManager(
|
|||||||
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
|
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
showMessage(message)
|
if (!silent) showMessage(message)
|
||||||
SyncStatus.Failed(message)
|
SyncStatus.Failed(message)
|
||||||
}
|
}
|
||||||
queueRepository.invalidateLibraryCache()
|
queueRepository.invalidateLibraryCache()
|
||||||
_status.value = result
|
_status.value = result
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private fun elapsedMs(startNanos: Long): Long = (System.nanoTime() - startNanos) / 1_000_000
|
||||||
const val TAG = "SubtracksSync"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NoServerException : IllegalStateException()
|
private class NoServerException : IllegalStateException()
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.subtracks.R
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class SyncNotifier(
|
||||||
|
private val context: Context,
|
||||||
|
private val status: Flow<SyncStatus>,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
|
) {
|
||||||
|
private val manager = NotificationManagerCompat.from(context)
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
createChannel()
|
||||||
|
scope.launch {
|
||||||
|
status.collect { syncStatus ->
|
||||||
|
if (syncStatus is SyncStatus.Running && manager.areNotificationsEnabled()) {
|
||||||
|
manager.notify(ID, build())
|
||||||
|
} else {
|
||||||
|
manager.cancel(ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun build(): Notification =
|
||||||
|
NotificationCompat
|
||||||
|
.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_name)
|
||||||
|
.setContentTitle(context.getString(R.string.sync_notification_title))
|
||||||
|
.setContentText(context.getString(R.string.sync_notification_text))
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setProgress(0, 0, true)
|
||||||
|
.setContentIntent(launchIntent())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun launchIntent(): PendingIntent? =
|
||||||
|
context.packageManager
|
||||||
|
.getLaunchIntentForPackage(context.packageName)
|
||||||
|
?.let { PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE) }
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.sync_notification_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply { setShowBadge(false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ID = 2
|
||||||
|
private const val CHANNEL_ID = "sync"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,16 @@ import androidx.room3.useWriterConnection
|
|||||||
import androidx.sqlite.SQLiteStatement
|
import androidx.sqlite.SQLiteStatement
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.AlbumArtistRef
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.ArtistRefsCodec
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.DiscKey
|
import com.subtracks.data.model.DiscKey
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.SongArtistRef
|
||||||
import com.subtracks.data.model.SongGenre
|
import com.subtracks.data.model.SongGenre
|
||||||
import com.subtracks.data.source.MusicSource
|
import com.subtracks.data.source.MusicSource
|
||||||
import java.util.Arrays
|
import java.util.Arrays
|
||||||
@@ -20,66 +24,120 @@ class SyncService(
|
|||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val source: MusicSource,
|
private val source: MusicSource,
|
||||||
) {
|
) {
|
||||||
suspend fun sync() {
|
private var artistsSynced = 0
|
||||||
syncArtists()
|
private var albumsSynced = 0
|
||||||
syncAlbums()
|
private var songsSynced = 0
|
||||||
syncSongs()
|
private var playlistsSynced = 0
|
||||||
aggregatePlayData()
|
private var playlistSongsSynced = 0
|
||||||
|
private var pruned = 0
|
||||||
|
|
||||||
|
suspend fun sync(): SyncSummary {
|
||||||
|
val sources = db.sourcesDao()
|
||||||
|
val wasStale = sources.creditsStale(source.id) ?: true
|
||||||
|
val artistsChanged = syncArtists()
|
||||||
|
val albumsChanged = syncAlbums()
|
||||||
|
val songsChanged = syncSongs()
|
||||||
|
optimize()
|
||||||
|
if (artistsChanged || albumsChanged || songsChanged || wasStale) {
|
||||||
|
recomputeAggregates()
|
||||||
|
sources.setCreditsStale(source.id, false)
|
||||||
|
}
|
||||||
syncPlaylists()
|
syncPlaylists()
|
||||||
syncPlaylistSongs()
|
syncPlaylistSongs()
|
||||||
|
return SyncSummary(
|
||||||
|
artists = artistsSynced,
|
||||||
|
albums = albumsSynced,
|
||||||
|
songs = songsSynced,
|
||||||
|
playlists = playlistsSynced,
|
||||||
|
playlistSongs = playlistSongsSynced,
|
||||||
|
pruned = pruned,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncArtists() {
|
private suspend fun syncArtists(): Boolean {
|
||||||
val library = db.libraryDao()
|
val library = db.libraryDao()
|
||||||
val seen = HashedIds()
|
val seen = HashedIds()
|
||||||
|
var changed = false
|
||||||
source.artists().collect { batch ->
|
source.artists().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("artists", artistColumns, primaryKey, batch) { it.values() }
|
changed =
|
||||||
|
write("artists", artistColumns, primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.values() } ||
|
||||||
|
changed
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
|
artistsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
val prunedBefore = pruned
|
||||||
|
pruned += pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
||||||
|
return changed || pruned > prunedBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncAlbums() {
|
private suspend fun syncAlbums(): Boolean {
|
||||||
val library = db.libraryDao()
|
val library = db.libraryDao()
|
||||||
val seenAlbums = HashedIds()
|
val seenAlbums = HashedIds()
|
||||||
val seenDiscs = HashedIds()
|
val seenDiscs = HashedIds()
|
||||||
|
var changed = false
|
||||||
source.albums().collect { batch ->
|
source.albums().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
val discs =
|
val discs =
|
||||||
batch.flatMap { album ->
|
batch.flatMap { album ->
|
||||||
album.discTitles.map { (disc, title) -> Disc(album.sourceId, album.id, disc, title) }
|
album.discTitles.map { (disc, title) -> Disc(album.sourceId, album.id, disc, title) }
|
||||||
}
|
}
|
||||||
writeAlbumsAndDiscs(batch, discs)
|
changed = writeAlbumsAndDiscs(batch, discs) || changed
|
||||||
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
||||||
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
||||||
|
albumsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
|
val prunedBefore = pruned
|
||||||
|
pruned +=
|
||||||
|
pruneStaleIds(
|
||||||
|
seenAlbums,
|
||||||
|
{ library.albumIdsAfter(source.id, it, PRUNE_PAGE) },
|
||||||
|
{ library.deleteAlbums(source.id, it) },
|
||||||
|
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||||
|
)
|
||||||
|
pruned +=
|
||||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||||
deleteStaleDiscs(it)
|
deleteStaleDiscs(it)
|
||||||
}
|
}
|
||||||
|
return changed || pruned > prunedBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncSongs() {
|
private suspend fun syncSongs(): Boolean {
|
||||||
val library = db.libraryDao()
|
val library = db.libraryDao()
|
||||||
val seen = HashedIds()
|
val seen = HashedIds()
|
||||||
|
var changed = false
|
||||||
source.songs().collect { batch ->
|
source.songs().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
writeSongs(batch)
|
changed = writeSongs(batch) || changed
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
|
songsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
|
val prunedBefore = pruned
|
||||||
|
pruned +=
|
||||||
|
pruneStaleIds(
|
||||||
|
seen,
|
||||||
|
{ library.songIdsAfter(source.id, it, PRUNE_PAGE) },
|
||||||
|
{ library.deleteSongs(source.id, it) },
|
||||||
|
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||||
|
)
|
||||||
|
return changed || pruned > prunedBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun writeSongs(batch: List<Song>) {
|
private suspend fun writeSongs(batch: List<Song>): Boolean {
|
||||||
val names = batch.associate { it.id to it.genreNames() }
|
val names = batch.associate { it.id to it.genreNames() }
|
||||||
val genres =
|
val genres =
|
||||||
batch.flatMap { song ->
|
batch.flatMap { song ->
|
||||||
names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) }
|
names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) }
|
||||||
}
|
}
|
||||||
db.useWriterConnection { connection ->
|
val artists =
|
||||||
|
batch.flatMap { song ->
|
||||||
|
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
|
||||||
|
}
|
||||||
|
return db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.upsertChanged("songs", songColumns, primaryKey, batch) { it.values() }
|
val before = connection.totalChanges()
|
||||||
|
val resolved = connection.resolveSongSortNames(batch)
|
||||||
|
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
|
||||||
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||||
batch.forEach { song ->
|
batch.forEach { song ->
|
||||||
statement.bind(1, song.sourceId)
|
statement.bind(1, song.sourceId)
|
||||||
@@ -91,12 +149,106 @@ class SyncService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
|
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
|
||||||
|
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||||
|
batch.forEach { song ->
|
||||||
|
statement.bind(1, song.sourceId)
|
||||||
|
statement.bind(2, song.id)
|
||||||
|
statement.bind(3, song.artistPositions().size.toLong())
|
||||||
|
statement.step()
|
||||||
|
statement.reset()
|
||||||
|
statement.clearBindings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
|
||||||
|
(connection.totalChanges() != before).also { changed ->
|
||||||
|
if (changed) connection.markCreditsStale(batch.first().sourceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun aggregatePlayData() {
|
private suspend fun recomputeAggregates() {
|
||||||
db.libraryDao().recomputePlayData(source.id)
|
db.libraryDao().recomputeAggregates(source.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.totalChanges(): Long {
|
||||||
|
var value = 0L
|
||||||
|
usePrepared("SELECT total_changes()") { statement ->
|
||||||
|
statement.step()
|
||||||
|
value = statement.getLong(0)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.markCreditsStale(sourceId: Long) {
|
||||||
|
usePrepared("UPDATE sources SET creditsStale = 1 WHERE id = ? AND creditsStale = 0") { statement ->
|
||||||
|
statement.bindLong(1, sourceId)
|
||||||
|
statement.step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.resolveSongSortNames(batch: List<Song>): List<Song> {
|
||||||
|
if (batch.isEmpty()) return batch
|
||||||
|
val sortNames =
|
||||||
|
artistSortNames(
|
||||||
|
batch.first().sourceId,
|
||||||
|
batch
|
||||||
|
.flatMap { listOfNotNull(it.artistId) + it.artistRefs.map { ref -> ref.id } }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
return batch.map { song ->
|
||||||
|
val primary = song.artistId ?: song.artists.firstOrNull()?.id
|
||||||
|
val sortName =
|
||||||
|
primary?.let(sortNames::get)?.takeIf { it.isNotBlank() }
|
||||||
|
?: song.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } }
|
||||||
|
?: song.artistSort
|
||||||
|
if (sortName == song.artistSort) song else song.copy(artistSort = sortName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.resolveAlbumSortNames(batch: List<Album>): List<Album> {
|
||||||
|
if (batch.isEmpty()) return batch
|
||||||
|
val sortNames =
|
||||||
|
artistSortNames(
|
||||||
|
batch.first().sourceId,
|
||||||
|
batch
|
||||||
|
.flatMap { listOfNotNull(it.artistId) + it.artistRefs.map { ref -> ref.id } }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.toSet(),
|
||||||
|
)
|
||||||
|
return batch.map { album ->
|
||||||
|
val primary = album.artistId ?: album.artists.firstOrNull()?.id
|
||||||
|
val sortName =
|
||||||
|
primary?.let(sortNames::get)?.takeIf { it.isNotBlank() }
|
||||||
|
?: album.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } }
|
||||||
|
?: album.albumArtistSort
|
||||||
|
if (sortName == album.albumArtistSort) album else album.copy(albumArtistSort = sortName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.artistSortNames(
|
||||||
|
sourceId: Long,
|
||||||
|
ids: Collection<String>,
|
||||||
|
): Map<String, String> {
|
||||||
|
if (ids.isEmpty()) return emptyMap()
|
||||||
|
val sortNames = HashMap<String, String>(ids.size)
|
||||||
|
ids.toList().chunked(SQL_VARIABLES).forEach { chunk ->
|
||||||
|
val placeholders = chunk.joinToString(",") { "?" }
|
||||||
|
usePrepared("SELECT id, sortName FROM artists WHERE sourceId = ? AND id IN ($placeholders)") { statement ->
|
||||||
|
statement.bindLong(1, sourceId)
|
||||||
|
chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) }
|
||||||
|
while (statement.step()) sortNames[statement.getText(0)] = statement.getText(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sortNames
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without sqlite_stat1 the artist and relation queries can fall back to whole-table scans.
|
||||||
|
private suspend fun optimize() {
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.usePrepared("PRAGMA optimize") { it.step() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncPlaylists() {
|
private suspend fun syncPlaylists() {
|
||||||
@@ -106,8 +258,9 @@ class SyncService(
|
|||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
|
playlistsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) })
|
pruned += pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) })
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncPlaylistSongs() {
|
private suspend fun syncPlaylistSongs() {
|
||||||
@@ -118,6 +271,7 @@ class SyncService(
|
|||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
||||||
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
||||||
|
playlistSongsSynced += batch.size
|
||||||
}
|
}
|
||||||
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
||||||
library.deleteOrphanPlaylistSongs(source.id)
|
library.deleteOrphanPlaylistSongs(source.id)
|
||||||
@@ -129,11 +283,16 @@ class SyncService(
|
|||||||
columns: List<String>,
|
columns: List<String>,
|
||||||
key: List<String>,
|
key: List<String>,
|
||||||
rows: List<T>,
|
rows: List<T>,
|
||||||
|
preserved: Set<String> = emptySet(),
|
||||||
|
markStale: Boolean = false,
|
||||||
values: (T) -> List<Any?>,
|
values: (T) -> List<Any?>,
|
||||||
) {
|
): Boolean =
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.upsertChanged(table, columns, key, rows, values)
|
val before = connection.totalChanges()
|
||||||
|
connection.upsertChanged(table, columns, key, rows, preserved, values)
|
||||||
|
(connection.totalChanges() != before).also { changed ->
|
||||||
|
if (changed && markStale) connection.markCreditsStale(source.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,11 +300,31 @@ class SyncService(
|
|||||||
private suspend fun writeAlbumsAndDiscs(
|
private suspend fun writeAlbumsAndDiscs(
|
||||||
albums: List<Album>,
|
albums: List<Album>,
|
||||||
discs: List<Disc>,
|
discs: List<Disc>,
|
||||||
) {
|
): Boolean {
|
||||||
db.useWriterConnection { connection ->
|
val artists =
|
||||||
|
albums.flatMap { album ->
|
||||||
|
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
|
||||||
|
}
|
||||||
|
return db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
|
val before = connection.totalChanges()
|
||||||
|
val resolved = connection.resolveAlbumSortNames(albums)
|
||||||
|
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
|
||||||
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
||||||
|
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
|
||||||
|
albums.forEach { album ->
|
||||||
|
statement.bind(1, album.sourceId)
|
||||||
|
statement.bind(2, album.id)
|
||||||
|
statement.bind(3, album.artistPositions().size.toLong())
|
||||||
|
statement.step()
|
||||||
|
statement.reset()
|
||||||
|
statement.clearBindings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
|
||||||
|
(connection.totalChanges() != before).also { changed ->
|
||||||
|
if (changed) connection.markCreditsStale(albums.first().sourceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,15 +352,19 @@ class SyncService(
|
|||||||
seen: HashedIds,
|
seen: HashedIds,
|
||||||
pageOf: suspend (afterId: String) -> List<String>,
|
pageOf: suspend (afterId: String) -> List<String>,
|
||||||
delete: suspend (Collection<String>) -> Unit,
|
delete: suspend (Collection<String>) -> Unit,
|
||||||
) {
|
markStale: (suspend () -> Unit)? = null,
|
||||||
|
): Int {
|
||||||
seen.freeze()
|
seen.freeze()
|
||||||
var afterId = ""
|
var afterId = ""
|
||||||
|
var pruned = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
val ids = pageOf(afterId)
|
val ids = pageOf(afterId)
|
||||||
if (ids.isEmpty()) return
|
if (ids.isEmpty()) return pruned
|
||||||
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
||||||
|
if (stale.isNotEmpty()) markStale?.invoke()
|
||||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||||
if (ids.size < PRUNE_PAGE) return
|
pruned += stale.size
|
||||||
|
if (ids.size < PRUNE_PAGE) return pruned
|
||||||
afterId = ids.last()
|
afterId = ids.last()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,23 +373,35 @@ class SyncService(
|
|||||||
seen: HashedIds,
|
seen: HashedIds,
|
||||||
pageOf: suspend (afterAlbumId: String, afterDisc: Long) -> List<DiscKey>,
|
pageOf: suspend (afterAlbumId: String, afterDisc: Long) -> List<DiscKey>,
|
||||||
delete: suspend (Collection<DiscKey>) -> Unit,
|
delete: suspend (Collection<DiscKey>) -> Unit,
|
||||||
) {
|
): Int {
|
||||||
seen.freeze()
|
seen.freeze()
|
||||||
var afterAlbumId = ""
|
var afterAlbumId = ""
|
||||||
var afterDisc = 0L
|
var afterDisc = 0L
|
||||||
|
var pruned = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
val keys = pageOf(afterAlbumId, afterDisc)
|
val keys = pageOf(afterAlbumId, afterDisc)
|
||||||
if (keys.isEmpty()) return
|
if (keys.isEmpty()) return pruned
|
||||||
val stale = keys.filterNot { seen.contains(discHash(it.albumId, it.disc)) }
|
val stale = keys.filterNot { seen.contains(discHash(it.albumId, it.disc)) }
|
||||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||||
if (keys.size < PRUNE_PAGE) return
|
pruned += stale.size
|
||||||
|
if (keys.size < PRUNE_PAGE) return pruned
|
||||||
afterAlbumId = keys.last().albumId
|
afterAlbumId = keys.last().albumId
|
||||||
afterDisc = keys.last().disc
|
afterDisc = keys.last().disc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class SyncSummary(
|
||||||
|
val artists: Int,
|
||||||
|
val albums: Int,
|
||||||
|
val songs: Int,
|
||||||
|
val playlists: Int,
|
||||||
|
val playlistSongs: Int,
|
||||||
|
val pruned: Int,
|
||||||
|
)
|
||||||
|
|
||||||
private const val DELETE_CHUNK = 500
|
private const val DELETE_CHUNK = 500
|
||||||
|
private const val SQL_VARIABLES = 500
|
||||||
private const val PRUNE_PAGE = 500
|
private const val PRUNE_PAGE = 500
|
||||||
private const val INITIAL_HASH_CAPACITY = 256
|
private const val INITIAL_HASH_CAPACITY = 256
|
||||||
private const val FNV_OFFSET = -3750763034362895579L
|
private const val FNV_OFFSET = -3750763034362895579L
|
||||||
@@ -243,7 +438,7 @@ private fun discHash(
|
|||||||
|
|
||||||
private val primaryKey = listOf("sourceId", "id")
|
private val primaryKey = listOf("sourceId", "id")
|
||||||
|
|
||||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt")
|
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "artistImageUrl", "sortName")
|
||||||
|
|
||||||
private val albumColumns =
|
private val albumColumns =
|
||||||
listOf(
|
listOf(
|
||||||
@@ -258,6 +453,9 @@ private val albumColumns =
|
|||||||
"year",
|
"year",
|
||||||
"starred",
|
"starred",
|
||||||
"songCount",
|
"songCount",
|
||||||
|
"artists",
|
||||||
|
"sortName",
|
||||||
|
"albumArtistSort",
|
||||||
)
|
)
|
||||||
|
|
||||||
private val discKey = listOf("sourceId", "albumId", "disc")
|
private val discKey = listOf("sourceId", "albumId", "disc")
|
||||||
@@ -280,9 +478,17 @@ private val songColumns =
|
|||||||
"created",
|
"created",
|
||||||
"playCount",
|
"playCount",
|
||||||
"played",
|
"played",
|
||||||
|
"trackGain",
|
||||||
|
"albumGain",
|
||||||
|
"trackPeak",
|
||||||
|
"albumPeak",
|
||||||
|
"artists",
|
||||||
|
"sortName",
|
||||||
|
"artistSort",
|
||||||
)
|
)
|
||||||
|
|
||||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
private val playlistColumns =
|
||||||
|
listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration", "owner", "shared")
|
||||||
|
|
||||||
private fun Song.genreNames(): List<String> =
|
private fun Song.genreNames(): List<String> =
|
||||||
genres
|
genres
|
||||||
@@ -298,13 +504,41 @@ private val songGenreColumns = listOf("sourceId", "songId", "position", "genre")
|
|||||||
|
|
||||||
private val songGenreKey = listOf("sourceId", "songId", "position")
|
private val songGenreKey = listOf("sourceId", "songId", "position")
|
||||||
|
|
||||||
|
private val albumArtistColumns = listOf("sourceId", "albumId", "position", "artistId", "name")
|
||||||
|
|
||||||
|
private val albumArtistKey = listOf("sourceId", "albumId", "position")
|
||||||
|
|
||||||
|
private val songArtistColumns = listOf("sourceId", "songId", "position", "artistId", "name")
|
||||||
|
|
||||||
|
private val songArtistKey = listOf("sourceId", "songId", "position")
|
||||||
|
|
||||||
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
||||||
|
|
||||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||||
|
|
||||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt)
|
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, artistImageUrl, sortName)
|
||||||
|
|
||||||
private fun Album.values() = listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount)
|
private fun Album.values() =
|
||||||
|
listOf<Any?>(
|
||||||
|
sourceId,
|
||||||
|
id,
|
||||||
|
artistId,
|
||||||
|
name,
|
||||||
|
albumArtist,
|
||||||
|
created,
|
||||||
|
coverArt,
|
||||||
|
genre,
|
||||||
|
year,
|
||||||
|
starred,
|
||||||
|
songCount,
|
||||||
|
ArtistRefsCodec.encode(artistRefs),
|
||||||
|
sortName,
|
||||||
|
albumArtistSort,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun AlbumArtistRef.values() = listOf<Any?>(sourceId, albumId, position, artistId, name)
|
||||||
|
|
||||||
|
private fun SongArtistRef.values() = listOf<Any?>(sourceId, songId, position, artistId, name)
|
||||||
|
|
||||||
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
||||||
|
|
||||||
@@ -324,11 +558,27 @@ private fun Song.values() =
|
|||||||
created,
|
created,
|
||||||
playCount,
|
playCount,
|
||||||
played,
|
played,
|
||||||
|
trackGain,
|
||||||
|
albumGain,
|
||||||
|
trackPeak,
|
||||||
|
albumPeak,
|
||||||
|
ArtistRefsCodec.encode(artistRefs),
|
||||||
|
sortName,
|
||||||
|
artistSort,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun List<ArtistRef>.positions(): List<Pair<Long, ArtistRef>> =
|
||||||
|
filter { it.id.isNotBlank() }
|
||||||
|
.distinctBy { it.id }
|
||||||
|
.mapIndexed { index, ref -> index.toLong() to ref }
|
||||||
|
|
||||||
|
private fun Album.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
||||||
|
|
||||||
|
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
||||||
|
|
||||||
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
||||||
|
|
||||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration, owner, shared)
|
||||||
|
|
||||||
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
|
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
|
||||||
|
|
||||||
@@ -337,10 +587,12 @@ private suspend fun <T> PooledConnection.upsertChanged(
|
|||||||
columns: List<String>,
|
columns: List<String>,
|
||||||
key: List<String>,
|
key: List<String>,
|
||||||
rows: List<T>,
|
rows: List<T>,
|
||||||
|
preserved: Set<String> = emptySet(),
|
||||||
values: (T) -> List<Any?>,
|
values: (T) -> List<Any?>,
|
||||||
) {
|
) {
|
||||||
if (rows.isEmpty()) return
|
if (rows.isEmpty()) return
|
||||||
val updates = columns.filterNot { it in key }
|
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
|
||||||
|
val updates = columns.filterNot { it in key || it in preserved }
|
||||||
val sql =
|
val sql =
|
||||||
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
|
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
|
||||||
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
||||||
@@ -367,6 +619,7 @@ private fun SQLiteStatement.bind(
|
|||||||
is Int -> bindLong(index, value.toLong())
|
is Int -> bindLong(index, value.toLong())
|
||||||
is Boolean -> bindLong(index, if (value) 1L else 0L)
|
is Boolean -> bindLong(index, if (value) 1L else 0L)
|
||||||
is Double -> bindDouble(index, value)
|
is Double -> bindDouble(index, value)
|
||||||
|
is Float -> bindDouble(index, value.toDouble())
|
||||||
is String -> bindText(index, value)
|
is String -> bindText(index, value)
|
||||||
else -> error("Cannot bind ${value::class}")
|
else -> error("Cannot bind ${value::class}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import com.subtracks.data.repo.PendingServerActionSink
|
|||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
|
import com.subtracks.data.sync.AutoSync
|
||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
|
import com.subtracks.data.sync.SyncNotifier
|
||||||
|
import com.subtracks.log.Log
|
||||||
import com.subtracks.playback.MediaSessionConnection
|
import com.subtracks.playback.MediaSessionConnection
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.playback.PlayerConnection
|
import com.subtracks.playback.PlayerConnection
|
||||||
@@ -48,6 +51,8 @@ import com.subtracks.ui.settings.SettingsViewModel
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.core.module.dsl.viewModel
|
import org.koin.core.module.dsl.viewModel
|
||||||
import org.koin.core.qualifier.named
|
import org.koin.core.qualifier.named
|
||||||
@@ -68,6 +73,7 @@ fun appModule(
|
|||||||
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
|
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
|
||||||
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
|
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
|
||||||
|
single(createdAtStart = true) { get<UserPreferences>().verboseLogging().onEach(Log::setVerbose).launchIn(get()) }
|
||||||
single {
|
single {
|
||||||
SourceRepository(
|
SourceRepository(
|
||||||
get(),
|
get(),
|
||||||
@@ -90,9 +96,18 @@ fun appModule(
|
|||||||
scope = get(),
|
scope = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
|
single { LibraryRepository(get(), get(), get(), get(), toast, scope = get(named("io"))) }
|
||||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||||
single { SyncManager(get(), get(), get(), get(), showMessage = toast, scope = get()) }
|
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
||||||
|
single {
|
||||||
|
AutoSync(
|
||||||
|
preferences = get(),
|
||||||
|
networkMode = networkMode(context.applicationContext),
|
||||||
|
hasSource = { get<SourceRepository>().activeSourceIdOnce() != null },
|
||||||
|
requestSync = { get<SyncManager>().requestSync(silent = true) },
|
||||||
|
scope = get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
single { QueueRepository(get()) }
|
single { QueueRepository(get()) }
|
||||||
single { ArtworkStore(downloadsRoot(context)) }
|
single { ArtworkStore(downloadsRoot(context)) }
|
||||||
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
|
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
|
||||||
@@ -111,6 +126,7 @@ fun appModule(
|
|||||||
).also { it.start() }
|
).also { it.start() }
|
||||||
}
|
}
|
||||||
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
||||||
|
single(createdAtStart = true) { SyncNotifier(context.applicationContext, get<SyncManager>().status, scope = get()).also { it.start() } }
|
||||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
||||||
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
||||||
single(createdAtStart = true) {
|
single(createdAtStart = true) {
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package com.subtracks.log
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.subtracks.BuildConfig
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.db.SqlExecution
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.math.roundToLong
|
||||||
|
import android.util.Log as AndroidLog
|
||||||
|
|
||||||
|
object Log {
|
||||||
|
private const val SLOW_QUERY_MS = 100.0
|
||||||
|
private const val MAX_SQL_CHARS = 500
|
||||||
|
private val WHITESPACE = Regex("\\s+")
|
||||||
|
|
||||||
|
private var fileStore: LogFileStore? = null
|
||||||
|
private var appContext: Context? = null
|
||||||
|
private var consoleLevel = LogLevel.DEBUG
|
||||||
|
private var defaultFileLevel = LogLevel.INFO
|
||||||
|
private var crashHandlerInstalled = false
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
val app = context.applicationContext
|
||||||
|
appContext = app
|
||||||
|
consoleLevel = if (BuildConfig.DEBUG) LogLevel.VERBOSE else LogLevel.INFO
|
||||||
|
defaultFileLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
|
||||||
|
fileStore?.shutdown()
|
||||||
|
fileStore = LogFileStore(File(app.filesDir, "logs"), defaultFileLevel)
|
||||||
|
installCrashHandler()
|
||||||
|
i("app", "started ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) on Android ${Build.VERSION.SDK_INT} ${Build.MODEL}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setVerbose(enabled: Boolean) {
|
||||||
|
fileStore?.setMinLevel(if (enabled) LogLevel.VERBOSE else defaultFileLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun v(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
) = log(LogLevel.VERBOSE, tag, message, null)
|
||||||
|
|
||||||
|
fun d(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
) = log(LogLevel.DEBUG, tag, message, null)
|
||||||
|
|
||||||
|
fun i(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
) = log(LogLevel.INFO, tag, message, null)
|
||||||
|
|
||||||
|
fun w(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable? = null,
|
||||||
|
) = log(LogLevel.WARN, tag, message, throwable)
|
||||||
|
|
||||||
|
fun e(
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable? = null,
|
||||||
|
) = log(LogLevel.ERROR, tag, message, throwable)
|
||||||
|
|
||||||
|
fun exportZip(): File? {
|
||||||
|
val store = fileStore ?: return null
|
||||||
|
val context = appContext ?: return null
|
||||||
|
store.flush()
|
||||||
|
return store.exportZip(File(context.cacheDir, "log_dumps"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun share(
|
||||||
|
context: Context,
|
||||||
|
file: File,
|
||||||
|
) {
|
||||||
|
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
val send =
|
||||||
|
Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "application/zip"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.settings_about_share_logs_subject))
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
context.startActivity(
|
||||||
|
Intent.createChooser(send, context.getString(R.string.settings_about_actions_share_logs)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interceptor(): Interceptor =
|
||||||
|
Interceptor { chain ->
|
||||||
|
val request = chain.request()
|
||||||
|
v("http", "${request.method} ${request.url}")
|
||||||
|
try {
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
if (response.code >= 400) {
|
||||||
|
w("http", "${request.method} ${request.url} -> ${response.code}")
|
||||||
|
}
|
||||||
|
response
|
||||||
|
} catch (failure: IOException) {
|
||||||
|
if (chain.call().isCanceled()) {
|
||||||
|
v("http", "${request.method} ${request.url} cancelled")
|
||||||
|
} else {
|
||||||
|
w("http", "${request.method} ${request.url} failed", failure)
|
||||||
|
}
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sql(execution: SqlExecution) {
|
||||||
|
val failure = execution.error
|
||||||
|
if (failure != null) {
|
||||||
|
if (isUpsertConflict(execution.sql, failure)) {
|
||||||
|
v("sql", "upsert fallback conflict: ${shortSql(execution.sql)}")
|
||||||
|
} else {
|
||||||
|
e("sql", "failed: ${shortSql(execution.sql)}", failure)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isSlowQuery(execution, SLOW_QUERY_MS)) return
|
||||||
|
val rows = if (isReadStatement(execution.sql)) " rows=${execution.rows}" else ""
|
||||||
|
w("sql", "slow ${execution.durationMillis.roundToLong()}ms$rows: ${shortSql(execution.sql)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun log(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
val toConsole = level.ordinal >= consoleLevel.ordinal
|
||||||
|
val store = fileStore
|
||||||
|
if (!toConsole && store?.willLog(level) != true) return
|
||||||
|
val redacted = redactLogs(message)
|
||||||
|
if (toConsole) {
|
||||||
|
// Logging is best effort: a logcat write must never disturb the caller.
|
||||||
|
runCatching {
|
||||||
|
val text =
|
||||||
|
if (throwable != null) {
|
||||||
|
"$redacted\n${redactLogs(AndroidLog.getStackTraceString(throwable))}"
|
||||||
|
} else {
|
||||||
|
redacted
|
||||||
|
}
|
||||||
|
AndroidLog.println(priority(level), tag.take(23), text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store?.write(level, tag, redacted, throwable)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun priority(level: LogLevel): Int =
|
||||||
|
when (level) {
|
||||||
|
LogLevel.VERBOSE -> AndroidLog.VERBOSE
|
||||||
|
LogLevel.DEBUG -> AndroidLog.DEBUG
|
||||||
|
LogLevel.INFO -> AndroidLog.INFO
|
||||||
|
LogLevel.WARN -> AndroidLog.WARN
|
||||||
|
LogLevel.ERROR -> AndroidLog.ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installCrashHandler() {
|
||||||
|
if (crashHandlerInstalled) return
|
||||||
|
crashHandlerInstalled = true
|
||||||
|
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
runCatching {
|
||||||
|
fileStore?.writeImmediately(LogLevel.ERROR, "crash", "uncaught exception on ${thread.name}", throwable)
|
||||||
|
}
|
||||||
|
previous?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortSql(sql: String): String = sql.replace(WHITESPACE, " ").take(MAX_SQL_CHARS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DATA_KEYWORDS = setOf("SELECT", "INSERT", "UPDATE", "DELETE", "WITH", "REPLACE")
|
||||||
|
private val READ_KEYWORDS = setOf("SELECT", "WITH")
|
||||||
|
private const val SQL_INTERNAL = "room_table_modification_log"
|
||||||
|
|
||||||
|
internal fun isDataStatement(sql: String): Boolean {
|
||||||
|
val normalized = sql.trimStart()
|
||||||
|
if (normalized.contains(SQL_INTERNAL)) return false
|
||||||
|
return normalized.takeWhile { !it.isWhitespace() }.uppercase() in DATA_KEYWORDS
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isReadStatement(sql: String): Boolean = sql.trimStart().takeWhile { !it.isWhitespace() }.uppercase() in READ_KEYWORDS
|
||||||
|
|
||||||
|
internal fun isSlowQuery(
|
||||||
|
execution: SqlExecution,
|
||||||
|
thresholdMs: Double,
|
||||||
|
): Boolean = execution.error == null && execution.durationMillis >= thresholdMs && isDataStatement(execution.sql)
|
||||||
|
|
||||||
|
// Room's EntityUpsertAdapter implements @Upsert as an INSERT and treats a uniqueness conflict as the
|
||||||
|
// signal to UPDATE instead (EntityUpsertAdapter.checkUniquenessException). Only downgrade a failed
|
||||||
|
// INSERT whose error says the conflict was a unique/primary key; anything else stays an ERROR so a
|
||||||
|
// genuine failure isn't hidden in release.
|
||||||
|
private const val CONSTRAINT_UNIQUE_MESSAGE = "unique constraint failed"
|
||||||
|
private const val CONSTRAINT_PRIMARY_KEY_MESSAGE = "primary key constraint failed"
|
||||||
|
|
||||||
|
internal fun isUpsertConflict(
|
||||||
|
sql: String,
|
||||||
|
error: Throwable,
|
||||||
|
): Boolean {
|
||||||
|
if (!sql.trimStart().uppercase().startsWith("INSERT")) return false
|
||||||
|
val message = error.message?.lowercase() ?: return false
|
||||||
|
return message.contains(CONSTRAINT_UNIQUE_MESSAGE) || message.contains(CONSTRAINT_PRIMARY_KEY_MESSAGE)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package com.subtracks.log
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
enum class LogLevel { VERBOSE, DEBUG, INFO, WARN, ERROR }
|
||||||
|
|
||||||
|
class LogFileStore(
|
||||||
|
private val rootDir: File,
|
||||||
|
@Volatile private var minLevel: LogLevel,
|
||||||
|
private val maxFileBytes: Long = DEFAULT_MAX_FILE_BYTES,
|
||||||
|
private val maxFiles: Int = DEFAULT_MAX_FILES,
|
||||||
|
) {
|
||||||
|
private val executor =
|
||||||
|
Executors.newSingleThreadExecutor { runnable ->
|
||||||
|
Thread(runnable, "log-writer").apply { isDaemon = true }
|
||||||
|
}
|
||||||
|
private val lock = Any()
|
||||||
|
private val pending = AtomicInteger()
|
||||||
|
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
|
private val lineFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
|
||||||
|
private var current = currentFile()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Log.init runs in Application.onCreate, so clean up leftovers on the writer thread.
|
||||||
|
runCatching { executor.execute { synchronized(lock) { prune() } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun willLog(level: LogLevel): Boolean = level.ordinal >= minLevel.ordinal
|
||||||
|
|
||||||
|
fun write(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
if (level.ordinal < minLevel.ordinal) return
|
||||||
|
// Drop under pressure rather than grow the queue without bound (verbose logs every request).
|
||||||
|
if (pending.get() >= MAX_PENDING) return
|
||||||
|
pending.incrementAndGet()
|
||||||
|
runCatching {
|
||||||
|
executor.execute {
|
||||||
|
try {
|
||||||
|
synchronized(lock) { append(level, tag, message, throwable) }
|
||||||
|
} finally {
|
||||||
|
pending.decrementAndGet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { pending.decrementAndGet() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeImmediately(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
if (level.ordinal < minLevel.ordinal) return
|
||||||
|
runCatching { synchronized(lock) { append(level, tag, message, throwable) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flush() {
|
||||||
|
runCatching { executor.submit {}.get() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMinLevel(level: LogLevel) {
|
||||||
|
minLevel = level
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shutdown() {
|
||||||
|
executor.shutdownNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun files(): List<File> =
|
||||||
|
rootDir
|
||||||
|
.listFiles { file -> file.isFile && file.name.endsWith(LOG_SUFFIX) }
|
||||||
|
?.sortedByDescending { it.lastModified() }
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
fun exportZip(destDir: File): File? =
|
||||||
|
synchronized(lock) {
|
||||||
|
val logs = files()
|
||||||
|
if (logs.isEmpty()) return null
|
||||||
|
destDir.mkdirs()
|
||||||
|
destDir.listFiles()?.forEach { if (it.isFile) it.delete() }
|
||||||
|
val zip = File(destDir, "subtracks-logs-${System.currentTimeMillis()}.zip")
|
||||||
|
ZipOutputStream(zip.outputStream().buffered()).use { out ->
|
||||||
|
logs.forEach { log ->
|
||||||
|
if (!log.isFile) return@forEach
|
||||||
|
out.putNextEntry(ZipEntry(log.name))
|
||||||
|
log.inputStream().use { it.copyTo(out) }
|
||||||
|
out.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zip
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun append(
|
||||||
|
level: LogLevel,
|
||||||
|
tag: String,
|
||||||
|
message: String,
|
||||||
|
throwable: Throwable?,
|
||||||
|
) {
|
||||||
|
val text =
|
||||||
|
buildString {
|
||||||
|
append(lineFormat.format(Date()))
|
||||||
|
append(' ')
|
||||||
|
append(LEVEL_CHARS[level.ordinal])
|
||||||
|
append(' ')
|
||||||
|
append(tag)
|
||||||
|
append(' ')
|
||||||
|
append(message)
|
||||||
|
throwable?.let {
|
||||||
|
append('\n')
|
||||||
|
append(it.stackTraceToString().trimEnd())
|
||||||
|
}
|
||||||
|
append('\n')
|
||||||
|
}
|
||||||
|
rootDir.mkdirs()
|
||||||
|
var rolled = false
|
||||||
|
val today = currentFile()
|
||||||
|
if (current.name != today.name) {
|
||||||
|
current = today
|
||||||
|
rolled = true
|
||||||
|
}
|
||||||
|
if (current.length() >= maxFileBytes) {
|
||||||
|
roll()
|
||||||
|
rolled = true
|
||||||
|
}
|
||||||
|
current.appendText(redactLogs(text))
|
||||||
|
if (rolled) prune()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rollSequence = 0
|
||||||
|
|
||||||
|
private fun roll() {
|
||||||
|
if (current.exists()) {
|
||||||
|
current.renameTo(uniqueRolledFile())
|
||||||
|
}
|
||||||
|
current = currentFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uniqueRolledFile(): File {
|
||||||
|
val day = dateFormat.format(Date())
|
||||||
|
while (true) {
|
||||||
|
val candidate = File(rootDir, "$day-${rollSequence++}$LOG_SUFFIX")
|
||||||
|
if (!candidate.exists()) return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prune() {
|
||||||
|
// Never prune the file we are writing to, and never keep fewer than one rolled file slot.
|
||||||
|
val rolled = files().filterNot { it == current }
|
||||||
|
if (rolled.size > maxFiles - 1) rolled.drop(maxFiles - 1).forEach { it.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentFile(): File = File(rootDir, dateFormat.format(Date()) + LOG_SUFFIX)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_MAX_FILE_BYTES = 1L * 1024 * 1024
|
||||||
|
const val DEFAULT_MAX_FILES = 7
|
||||||
|
private const val MAX_PENDING = 4096
|
||||||
|
private const val LOG_SUFFIX = ".log"
|
||||||
|
private val LEVEL_CHARS = charArrayOf('V', 'D', 'I', 'W', 'E')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val URL_USERINFO = Regex("([a-zA-Z][a-zA-Z0-9+.\\-]*://)[^/@\\s]+@")
|
||||||
|
private val URL_PARAM = Regex("([?&](?:u|p|s|t)=)[^&\\s]+")
|
||||||
|
|
||||||
|
internal fun redactLogs(input: String): String = input.replace(URL_USERINFO, "$1REDACTED@").replace(URL_PARAM, "$1REDACTED")
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.subtracks.playback
|
||||||
|
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.ForwardingPlayer
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports a track whose duration is known from its metadata as finite rather than live.
|
||||||
|
*
|
||||||
|
* Media3 marks a progressive stream live when the data source reports no length and the extractor
|
||||||
|
* no duration. The app declares a length only for a transcode of a track that has one in a
|
||||||
|
* length-estimable format, so a chunked response stays live for an Opus/Ogg/Vorbis transcode, for
|
||||||
|
* a transcode of a track whose library duration is missing, and for a direct play the server does
|
||||||
|
* not give a Content-Length. A live item makes the media session publish no playback position, so
|
||||||
|
* the notification loses its progress even though the item carries a real duration. The stream is
|
||||||
|
* still not seekable; this only restores the position the session reports.
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
internal class KnownDurationPlayer(
|
||||||
|
player: Player,
|
||||||
|
) : ForwardingPlayer(player) {
|
||||||
|
override fun isCurrentMediaItemLive(): Boolean = if (declaredDurationMs() != null) false else super.isCurrentMediaItemLive()
|
||||||
|
|
||||||
|
private fun declaredDurationMs(): Long? =
|
||||||
|
currentMediaItem
|
||||||
|
?.mediaMetadata
|
||||||
|
?.durationMs
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Format
|
import androidx.media3.common.Format
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
@@ -13,9 +12,12 @@ import androidx.media3.common.util.UnstableApi
|
|||||||
import androidx.media3.session.MediaController
|
import androidx.media3.session.MediaController
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.ArtistRefsCodec
|
||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
class Media3PlayerHandle(
|
class Media3PlayerHandle(
|
||||||
private val controller: MediaController,
|
private val controller: MediaController,
|
||||||
@@ -112,14 +114,19 @@ class Media3PlayerHandle(
|
|||||||
) = controller.replaceMediaItem(index, toMediaItem(item))
|
) = controller.replaceMediaItem(index, toMediaItem(item))
|
||||||
|
|
||||||
override fun seekToIndex(index: Int) {
|
override fun seekToIndex(index: Int) {
|
||||||
Log.d(TAG, "seekToIndex($index) playerDuration=${controller.duration} state=${controller.playbackState}")
|
Log.d(
|
||||||
|
"playback",
|
||||||
|
"seekToIndex($index) position=${controller.currentPosition} duration=${duration(
|
||||||
|
controller.duration,
|
||||||
|
)} state=${controller.playbackState}",
|
||||||
|
)
|
||||||
controller.seekTo(index, 0)
|
controller.seekTo(index, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) {
|
override fun seekTo(positionMs: Long) {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
"playback",
|
||||||
"seekTo(${positionMs}ms) from=${controller.currentPosition} playerDuration=${controller.duration} " +
|
"seekTo(${positionMs}ms) from=${controller.currentPosition} duration=${duration(controller.duration)} " +
|
||||||
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
||||||
)
|
)
|
||||||
controller.seekTo(positionMs)
|
controller.seekTo(positionMs)
|
||||||
@@ -136,7 +143,7 @@ class Media3PlayerHandle(
|
|||||||
mediaItem: MediaItem?,
|
mediaItem: MediaItem?,
|
||||||
reason: Int,
|
reason: Int,
|
||||||
) {
|
) {
|
||||||
Log.d(TAG, "item=${mediaItem?.mediaId}")
|
Log.i("playback", "${describe(mediaItem)} transition=${transitionReason(reason)}")
|
||||||
listener.onTransition()
|
listener.onTransition()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +154,9 @@ class Media3PlayerHandle(
|
|||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
"playback",
|
||||||
"Playback error ${error.errorCodeName} at ${controller.currentPosition}/${controller.duration}",
|
"error ${error.errorCodeName} ${describe(controller.currentMediaItem)} " +
|
||||||
|
"position=${controller.currentPosition} duration=${duration(controller.duration)}",
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
listener.onError(playbackErrorMessage(error))
|
listener.onError(playbackErrorMessage(error))
|
||||||
@@ -156,15 +164,54 @@ class Media3PlayerHandle(
|
|||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
"playback",
|
||||||
"state=$playbackState position=${controller.currentPosition} " +
|
"state=$playbackState position=${controller.currentPosition} " +
|
||||||
"playerDuration=${controller.duration} itemDuration=${currentItem?.durationMs}",
|
"duration=${duration(controller.duration)} itemDuration=${currentItem?.durationMs}",
|
||||||
)
|
)
|
||||||
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
|
Log.i("playback", "ended ${describe(controller.currentMediaItem)}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun describe(mediaItem: MediaItem?): String {
|
||||||
|
if (mediaItem == null) return "item=none"
|
||||||
|
val metadata = mediaItem.mediaMetadata
|
||||||
|
val artist =
|
||||||
|
metadata.artist
|
||||||
|
?.toString()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { " artist=\"$it\"" }
|
||||||
|
.orEmpty()
|
||||||
|
val album =
|
||||||
|
metadata.albumTitle
|
||||||
|
?.toString()
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { " album=\"$it\"" }
|
||||||
|
.orEmpty()
|
||||||
|
val source =
|
||||||
|
when (mediaItem.localConfiguration?.uri?.scheme) {
|
||||||
|
"file", "content" -> "file"
|
||||||
|
"http", "https" -> "http"
|
||||||
|
null -> "none"
|
||||||
|
else -> "other"
|
||||||
|
}
|
||||||
|
return "item id=${mediaItem.mediaId} title=\"${metadata.title?.toString().orEmpty()}\"$artist$album source=$source"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun duration(value: Long): String = if (value == C.TIME_UNSET) "unset" else "${value}ms"
|
||||||
|
|
||||||
|
private fun transitionReason(reason: Int): String =
|
||||||
|
when (reason) {
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT -> "repeat"
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> "auto"
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> "seek"
|
||||||
|
Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "playlist"
|
||||||
|
else -> reason.toString()
|
||||||
|
}
|
||||||
|
|
||||||
private fun toMediaItem(item: QueueItem): MediaItem {
|
private fun toMediaItem(item: QueueItem): MediaItem {
|
||||||
val quality = sourceRepository.quality.value
|
val quality = sourceRepository.quality.value
|
||||||
return queueMediaItem(
|
return queueMediaItem(
|
||||||
@@ -184,20 +231,51 @@ class Media3PlayerHandle(
|
|||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||||
|
artists = mediaItem.artistRefs(),
|
||||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||||
durationMs =
|
durationMs =
|
||||||
mediaItem.mediaMetadata.extras
|
mediaItem.mediaMetadata.extras
|
||||||
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
||||||
?.getLong(EXTRA_DURATION_MS),
|
?.getLong(EXTRA_DURATION_MS),
|
||||||
|
replayGain = mediaItem.replayGain(),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
internal fun MediaItem.artistRefs(): List<ArtistRef> =
|
||||||
const val TAG = "SubtracksPlayback"
|
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,10 +696,27 @@ 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) =
|
||||||
@@ -771,21 +804,14 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
when {
|
val target = previousPosition(snapshot, current)
|
||||||
current > 0 -> {
|
if (target != null) {
|
||||||
jumpTo(current - 1)
|
jumpTo(target)
|
||||||
}
|
} else {
|
||||||
|
|
||||||
repeatMode == RepeatMode.All -> {
|
|
||||||
jumpTo(snapshot.size - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
player.seekTo(0)
|
player.seekTo(0)
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun previousTrack() {
|
fun previousTrack() {
|
||||||
val snapshot = snapshot ?: return
|
val snapshot = snapshot ?: return
|
||||||
@@ -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,22 +1506,43 @@ 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()) {
|
||||||
val player = player ?: return
|
val player = player ?: return
|
||||||
@@ -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,11 +781,30 @@ 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 ->
|
||||||
|
ArtistChooserSheet(
|
||||||
|
artists = artists,
|
||||||
|
onArtistClick = { artist ->
|
||||||
|
artistChooser = null
|
||||||
|
navigateDetail(
|
||||||
|
Routes.ARTIST_DETAIL,
|
||||||
|
"artistId",
|
||||||
|
artist.id,
|
||||||
|
Routes.artist(artist.id),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDismiss = { artistChooser = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val target = infoTarget) {
|
||||||
|
is MenuTarget.Song -> {
|
||||||
|
val song = target.song
|
||||||
val genres by
|
val genres by
|
||||||
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
@@ -738,10 +813,37 @@ private fun MainNavigation() {
|
|||||||
download = downloads[song.id],
|
download = downloads[song.id],
|
||||||
localFile = downloadRepository.localFile(song.id),
|
localFile = downloadRepository.localFile(song.id),
|
||||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||||
onDismiss = { infoSong = null },
|
onDismiss = { infoTarget = null },
|
||||||
genres = genres,
|
genres = genres,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Album -> {
|
||||||
|
AlbumInfoDialog(
|
||||||
|
album = target.album,
|
||||||
|
download = target.download,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Artist -> {
|
||||||
|
ArtistInfoDialog(
|
||||||
|
artist = target.artist,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MenuTarget.Playlist -> {
|
||||||
|
PlaylistInfoDialog(
|
||||||
|
playlist = target.playlist,
|
||||||
|
onDismiss = { infoTarget = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Outline
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
data class ArtistArt(
|
||||||
|
val image: CoverArtRef? = null,
|
||||||
|
val thumbnail: CoverArtRef? = null,
|
||||||
|
val covers: List<CoverArtRef> = emptyList(),
|
||||||
|
val fallback: ArtistArt? = null,
|
||||||
|
) {
|
||||||
|
val primary: CoverArtRef? get() = image ?: covers.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SourceRepository.artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt =
|
||||||
|
resolveArtistArt(
|
||||||
|
artist = artist,
|
||||||
|
coverArt = this::coverArt,
|
||||||
|
artistImage = this::artistImage,
|
||||||
|
offline = offline.value,
|
||||||
|
forIcon = forIcon,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun resolveArtistArt(
|
||||||
|
artist: Artist,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
artistImage: (String, String?) -> CoverArtRef?,
|
||||||
|
offline: Boolean,
|
||||||
|
forIcon: Boolean,
|
||||||
|
): ArtistArt {
|
||||||
|
val coverIds = if (offline) artist.downloadedCoverArts else artist.coverArts
|
||||||
|
val grid =
|
||||||
|
ArtistArt(
|
||||||
|
thumbnail = coverIds.singleOrNull()?.let { coverArt(it, true) },
|
||||||
|
covers = coverIds.take(4).mapNotNull { coverArt(it, forIcon || coverIds.size > 1) },
|
||||||
|
)
|
||||||
|
val own =
|
||||||
|
artist.coverArt?.let { coverId ->
|
||||||
|
coverArt(coverId, forIcon)?.let { image ->
|
||||||
|
ArtistArt(
|
||||||
|
image = image,
|
||||||
|
thumbnail = if (forIcon) image else coverArt(coverId, true),
|
||||||
|
fallback = grid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return artist.artistImageUrl
|
||||||
|
?.let { artistImage(artist.id, it) }
|
||||||
|
?.let { ArtistArt(image = it, thumbnail = it, fallback = own ?: grid) }
|
||||||
|
?: own
|
||||||
|
?: grid
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistIcon(
|
||||||
|
artist: Artist,
|
||||||
|
art: ArtistArt,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var shown by remember(artist.id, art) { mutableStateOf(art) }
|
||||||
|
val showFallback = {
|
||||||
|
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
shown.image != null -> {
|
||||||
|
CoverArt(
|
||||||
|
ref = shown.image,
|
||||||
|
name = artist.name,
|
||||||
|
modifier = modifier,
|
||||||
|
onError = showFallback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
shown.covers.size >= 2 -> {
|
||||||
|
ArtistArtGrid(covers = shown.covers, name = artist.name, modifier = modifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
CoverArt(ref = shown.covers.firstOrNull(), name = artist.name, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistHeroArt(
|
||||||
|
art: ArtistArt,
|
||||||
|
name: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var shown by remember(art) { mutableStateOf(art) }
|
||||||
|
val showFallback = {
|
||||||
|
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
shown.image != null -> {
|
||||||
|
CoverArt(
|
||||||
|
ref = shown.image,
|
||||||
|
name = name,
|
||||||
|
thumbnailRef = shown.thumbnail,
|
||||||
|
showPlaceholder = false,
|
||||||
|
modifier = modifier,
|
||||||
|
onError = showFallback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
shown.covers.isNotEmpty() -> {
|
||||||
|
ArtistArtGrid(
|
||||||
|
covers = shown.covers,
|
||||||
|
name = name,
|
||||||
|
singleThumbnail = shown.thumbnail,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
CoverArt(ref = null, name = name, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ArtistArtGrid(
|
||||||
|
covers: List<CoverArtRef>,
|
||||||
|
name: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
singleThumbnail: CoverArtRef? = null,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
covers.size >= 4 -> {
|
||||||
|
Column(modifier) {
|
||||||
|
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
covers.take(2).forEach { ref ->
|
||||||
|
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
covers.drop(2).take(2).forEach { ref ->
|
||||||
|
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
covers.size == 3 -> {
|
||||||
|
Box(modifier) {
|
||||||
|
covers.forEachIndexed { index, ref ->
|
||||||
|
CoverArt(
|
||||||
|
ref = ref,
|
||||||
|
name = name,
|
||||||
|
modifier = Modifier.matchParentSize().clip(YRegionShape(index)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
covers.size == 2 -> {
|
||||||
|
Row(modifier) {
|
||||||
|
covers.forEach { ref ->
|
||||||
|
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
covers.size == 1 -> {
|
||||||
|
CoverArt(
|
||||||
|
ref = covers.first(),
|
||||||
|
name = name,
|
||||||
|
thumbnailRef = singleThumbnail,
|
||||||
|
showPlaceholder = false,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class YRegionShape(
|
||||||
|
private val region: Int,
|
||||||
|
) : Shape {
|
||||||
|
override fun createOutline(
|
||||||
|
size: Size,
|
||||||
|
layoutDirection: LayoutDirection,
|
||||||
|
density: Density,
|
||||||
|
): Outline {
|
||||||
|
val center = Offset(size.width / 2f, size.height / 2f)
|
||||||
|
val left = rayPoint(size, 150.0)
|
||||||
|
val right = rayPoint(size, 30.0)
|
||||||
|
val top = Offset(size.width / 2f, 0f)
|
||||||
|
val path = Path()
|
||||||
|
when (region) {
|
||||||
|
0 -> {
|
||||||
|
path.moveTo(center.x, center.y)
|
||||||
|
path.lineTo(right.x, right.y)
|
||||||
|
addClockwise(path, size, right, left)
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> {
|
||||||
|
path.moveTo(center.x, center.y)
|
||||||
|
path.lineTo(left.x, left.y)
|
||||||
|
addClockwise(path, size, left, top)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
path.moveTo(center.x, center.y)
|
||||||
|
path.lineTo(top.x, top.y)
|
||||||
|
addClockwise(path, size, top, right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.lineTo(center.x, center.y)
|
||||||
|
path.close()
|
||||||
|
return Outline.Generic(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rayPoint(
|
||||||
|
size: Size,
|
||||||
|
angleDegrees: Double,
|
||||||
|
): Offset {
|
||||||
|
val cx = size.width / 2.0
|
||||||
|
val cy = size.height / 2.0
|
||||||
|
val radians = angleDegrees * PI / 180.0
|
||||||
|
val dx = cos(radians)
|
||||||
|
val dy = sin(radians)
|
||||||
|
val tx =
|
||||||
|
when {
|
||||||
|
dx > 0 -> (size.width - cx) / dx
|
||||||
|
dx < 0 -> -cx / dx
|
||||||
|
else -> Double.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
val ty =
|
||||||
|
when {
|
||||||
|
dy > 0 -> (size.height - cy) / dy
|
||||||
|
dy < 0 -> -cy / dy
|
||||||
|
else -> Double.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
val t = min(tx, ty)
|
||||||
|
return Offset((cx + dx * t).toFloat(), (cy + dy * t).toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addClockwise(
|
||||||
|
path: Path,
|
||||||
|
size: Size,
|
||||||
|
from: Offset,
|
||||||
|
to: Offset,
|
||||||
|
) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val corners =
|
||||||
|
listOf(
|
||||||
|
Offset(0f, 0f),
|
||||||
|
Offset(w, 0f),
|
||||||
|
Offset(w, h),
|
||||||
|
Offset(0f, h),
|
||||||
|
)
|
||||||
|
val fromParam = perimeterParam(from, size)
|
||||||
|
val toParam = perimeterParam(to, size)
|
||||||
|
if (fromParam <= toParam) {
|
||||||
|
corners
|
||||||
|
.filter { perimeterParam(it, size) > fromParam && perimeterParam(it, size) < toParam }
|
||||||
|
.forEach { path.lineTo(it.x, it.y) }
|
||||||
|
} else {
|
||||||
|
corners
|
||||||
|
.filter { perimeterParam(it, size) > fromParam }
|
||||||
|
.forEach { path.lineTo(it.x, it.y) }
|
||||||
|
corners
|
||||||
|
.filter { perimeterParam(it, size) < toParam }
|
||||||
|
.forEach { path.lineTo(it.x, it.y) }
|
||||||
|
}
|
||||||
|
path.lineTo(to.x, to.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun perimeterParam(
|
||||||
|
point: Offset,
|
||||||
|
size: Size,
|
||||||
|
): Float {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
return when {
|
||||||
|
point.y <= 0f -> point.x
|
||||||
|
point.x >= w -> w + point.y
|
||||||
|
point.y >= h -> w + h + (w - point.x)
|
||||||
|
else -> 2f * w + h + (h - point.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Person
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
|
||||||
|
const val ARTIST_CHOOSER_TAG = "artistChooser"
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ArtistChooserSheet(
|
||||||
|
artists: List<ArtistRef>,
|
||||||
|
onArtistClick: (ArtistRef) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
dragHandle = null,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
modifier = Modifier.testTag(ARTIST_CHOOSER_TAG),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.artist_chooser_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 400.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
artists.forEach { artist ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) },
|
||||||
|
leadingContent = { Icon(Icons.Rounded.Person, contentDescription = null) },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
modifier = Modifier.clickable { onArtistClick(artist) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,11 +85,16 @@ fun CoverArt(
|
|||||||
square: Boolean = true,
|
square: Boolean = true,
|
||||||
elevation: Dp = 0.dp,
|
elevation: Dp = 0.dp,
|
||||||
colorFilter: ColorFilter? = null,
|
colorFilter: ColorFilter? = null,
|
||||||
|
onError: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalPlatformContext.current
|
val context = LocalPlatformContext.current
|
||||||
var failed by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
val refKey = ref?.cacheKey
|
||||||
var thumbnailLoaded by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
val thumbnailKey = thumbnailRef?.cacheKey
|
||||||
var thumbnailRatio by remember(ref, thumbnailRef) { mutableStateOf(thumbnailRef?.cacheKey?.let(ArtworkRatioCache::get)) }
|
val hasThumbnailFallback = thumbnailRef != null && thumbnailRef != ref
|
||||||
|
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
|
var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
|
var thumbnailFailed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||||
|
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
|
||||||
val letterScheme =
|
val letterScheme =
|
||||||
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
||||||
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
|
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
|
||||||
@@ -102,7 +107,7 @@ fun CoverArt(
|
|||||||
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
|
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
|
||||||
if (square) {
|
if (square) {
|
||||||
val model = remember(ref, thumbnailRef) { ref?.let { imageRequest(context, it, crossfade = true) } }
|
val model = remember(refKey, thumbnailKey) { ref?.let { imageRequest(context, it, crossfade = true) } }
|
||||||
Box(modifier.then(frameModifier)) {
|
Box(modifier.then(frameModifier)) {
|
||||||
CoverArtContent(
|
CoverArtContent(
|
||||||
ref = ref,
|
ref = ref,
|
||||||
@@ -113,6 +118,10 @@ fun CoverArt(
|
|||||||
thumbnailLoaded = thumbnailLoaded,
|
thumbnailLoaded = thumbnailLoaded,
|
||||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||||
onThumbnailRatio = { thumbnailRatio = it },
|
onThumbnailRatio = { thumbnailRatio = it },
|
||||||
|
onThumbnailError = {
|
||||||
|
thumbnailFailed = true
|
||||||
|
if (failed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||||
@@ -129,7 +138,10 @@ fun CoverArt(
|
|||||||
.ratioOrNull()
|
.ratioOrNull()
|
||||||
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
|
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
|
||||||
},
|
},
|
||||||
onError = { failed = true },
|
onError = {
|
||||||
|
failed = true
|
||||||
|
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -147,7 +159,7 @@ fun CoverArt(
|
|||||||
val painter =
|
val painter =
|
||||||
rememberAsyncImagePainter(
|
rememberAsyncImagePainter(
|
||||||
model =
|
model =
|
||||||
remember(ref, thumbnailRef, target) {
|
remember(refKey, thumbnailKey, target) {
|
||||||
ref?.let {
|
ref?.let {
|
||||||
imageRequest(
|
imageRequest(
|
||||||
context,
|
context,
|
||||||
@@ -158,7 +170,10 @@ fun CoverArt(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError = { failed = true },
|
onError = {
|
||||||
|
failed = true
|
||||||
|
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
filterQuality = FilterQuality.High,
|
filterQuality = FilterQuality.High,
|
||||||
)
|
)
|
||||||
@@ -177,6 +192,10 @@ fun CoverArt(
|
|||||||
thumbnailLoaded = thumbnailLoaded,
|
thumbnailLoaded = thumbnailLoaded,
|
||||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||||
onThumbnailRatio = { thumbnailRatio = it },
|
onThumbnailRatio = { thumbnailRatio = it },
|
||||||
|
onThumbnailError = {
|
||||||
|
thumbnailFailed = true
|
||||||
|
if (failed) onError?.invoke()
|
||||||
|
},
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||||
@@ -206,6 +225,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
thumbnailLoaded: Boolean,
|
thumbnailLoaded: Boolean,
|
||||||
onThumbnailLoaded: () -> Unit,
|
onThumbnailLoaded: () -> Unit,
|
||||||
onThumbnailRatio: (Float) -> Unit,
|
onThumbnailRatio: (Float) -> Unit,
|
||||||
|
onThumbnailError: () -> Unit,
|
||||||
contentScale: ContentScale,
|
contentScale: ContentScale,
|
||||||
colorFilter: ColorFilter?,
|
colorFilter: ColorFilter?,
|
||||||
placeholderContentColor: Color?,
|
placeholderContentColor: Color?,
|
||||||
@@ -231,7 +251,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
}
|
}
|
||||||
if (thumbnailRef != null && thumbnailRef != ref) {
|
if (thumbnailRef != null && thumbnailRef != ref) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = remember(thumbnailRef) { imageRequest(context, thumbnailRef, crossfade = false) },
|
model = remember(thumbnailRef.cacheKey) { imageRequest(context, thumbnailRef, crossfade = false) },
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
@@ -244,6 +264,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
|
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onError = { onThumbnailError() },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.statusBars
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -47,11 +48,17 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -77,6 +84,7 @@ import com.subtracks.ui.theme.ArtworkTheme
|
|||||||
import com.subtracks.ui.theme.HeroGradient
|
import com.subtracks.ui.theme.HeroGradient
|
||||||
import com.subtracks.ui.theme.baseArtworkColors
|
import com.subtracks.ui.theme.baseArtworkColors
|
||||||
import com.subtracks.ui.theme.heroBarColor
|
import com.subtracks.ui.theme.heroBarColor
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private const val FADE_DISTANCE_DP = 64
|
private const val FADE_DISTANCE_DP = 64
|
||||||
|
|
||||||
@@ -96,12 +104,14 @@ fun HeroDetailScaffold(
|
|||||||
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
||||||
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
contentReady: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val effectiveArtwork = artwork ?: baseArtworkColors()
|
val effectiveArtwork = artwork ?: baseArtworkColors()
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
ArtworkTheme(effectiveArtwork) {
|
ArtworkTheme(effectiveArtwork) {
|
||||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberRestorableLazyListState(contentReady)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||||
@@ -111,9 +121,9 @@ fun HeroDetailScaffold(
|
|||||||
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||||
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
||||||
|
|
||||||
var headerHeightPx by remember { mutableFloatStateOf(0f) }
|
var headerHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
var rowHeightPx by remember { mutableFloatStateOf(0f) }
|
var rowHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
var controlsTopPx by remember { mutableFloatStateOf(0f) }
|
var controlsTopPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
val scrollPx by remember {
|
val scrollPx by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val index = listState.firstVisibleItemIndex
|
val index = listState.firstVisibleItemIndex
|
||||||
@@ -195,13 +205,39 @@ fun HeroDetailScaffold(
|
|||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null,
|
indication = null,
|
||||||
) {},
|
onClickLabel = stringResource(R.string.scroll_to_top),
|
||||||
|
) { scope.launch { listState.animateScrollToItem(0) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberRestorableLazyListState(contentReady: Boolean): LazyListState {
|
||||||
|
val state = rememberLazyListState()
|
||||||
|
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
|
||||||
|
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
|
||||||
|
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
|
||||||
|
LaunchedEffect(contentReady) {
|
||||||
|
if (contentReady && pending) {
|
||||||
|
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
|
||||||
|
state.scrollToItem(target, offset)
|
||||||
|
pending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
|
||||||
|
.collect { (currentIndex, currentOffset) ->
|
||||||
|
if (!pending) {
|
||||||
|
index = currentIndex
|
||||||
|
offset = currentOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HeroSubtitle(
|
private fun HeroSubtitle(
|
||||||
text: String,
|
text: String,
|
||||||
@@ -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
|
||||||
@@ -225,6 +236,13 @@ fun ItemContextMenu(
|
|||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
dragHandle = null,
|
dragHandle = null,
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = MENU_MAX_HEIGHT)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
MenuHeader(target)
|
MenuHeader(target)
|
||||||
when (target) {
|
when (target) {
|
||||||
@@ -246,18 +264,9 @@ fun ItemContextMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
target.song.artistId?.let { artistId ->
|
ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||||
actions.viewArtist?.let { view ->
|
|
||||||
MenuItem(
|
|
||||||
Icons.Rounded.Person,
|
|
||||||
target.song.artist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
|
||||||
) {
|
|
||||||
dismiss { view(artistId) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onInfo?.let { info ->
|
onInfo?.let { info ->
|
||||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,15 +278,9 @@ fun ItemContextMenu(
|
|||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,16 +291,23 @@ fun ItemContextMenu(
|
|||||||
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
||||||
onDismiss = { dismiss() },
|
onDismiss = { dismiss() },
|
||||||
)
|
)
|
||||||
|
onInfo?.let { info ->
|
||||||
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is MenuTarget.Playlist -> {
|
is MenuTarget.Playlist -> {
|
||||||
queueItems()
|
queueItems()
|
||||||
BulkDownloadItem(target, actions, ::dismiss)
|
BulkDownloadItem(target, actions, ::dismiss)
|
||||||
|
onInfo?.let { info ->
|
||||||
|
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MenuHeader(target: MenuTarget) {
|
private fun MenuHeader(target: MenuTarget) {
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import com.subtracks.data.model.Song
|
|||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistChooserSheet
|
||||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||||
@@ -66,16 +67,20 @@ fun AlbumDetailRoute(
|
|||||||
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
val album by viewModel.album.collectAsStateWithLifecycle()
|
||||||
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
val songs by viewModel.songs.collectAsStateWithLifecycle()
|
||||||
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
val discs by viewModel.discs.collectAsStateWithLifecycle()
|
||||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
var showArtistChooser by remember { mutableStateOf(false) }
|
||||||
|
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) {
|
||||||
|
pendingDelete = null
|
||||||
|
showArtistChooser = false
|
||||||
|
}
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val context = playback.context
|
val context = playback.context
|
||||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||||
@@ -126,9 +131,13 @@ fun AlbumDetailRoute(
|
|||||||
},
|
},
|
||||||
onArtistClick =
|
onArtistClick =
|
||||||
album
|
album
|
||||||
?.takeIf { !it.albumArtist.isNullOrBlank() }
|
?.takeIf { it.artistRefs.isNotEmpty() }
|
||||||
?.let { it.artistId }
|
?.let {
|
||||||
?.let { id -> { onViewArtist(id) } },
|
{
|
||||||
|
val artists = it.artistRefs
|
||||||
|
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
|
||||||
|
}
|
||||||
|
},
|
||||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,12 +149,24 @@ fun AlbumDetailRoute(
|
|||||||
onDismiss = { pendingDelete = null },
|
onDismiss = { pendingDelete = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showArtistChooser) {
|
||||||
|
ArtistChooserSheet(
|
||||||
|
artists = album?.artistRefs.orEmpty(),
|
||||||
|
onArtistClick = { artist ->
|
||||||
|
showArtistChooser = false
|
||||||
|
onViewArtist(artist.id)
|
||||||
|
},
|
||||||
|
onDismiss = { showArtistChooser = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
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 +181,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 +215,7 @@ fun AlbumDetailScreen(
|
|||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
title = album?.name.orEmpty(),
|
title = album?.name.orEmpty(),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
contentReady = songs.isNotEmpty(),
|
||||||
header = { controlsModifier, topInset ->
|
header = { controlsModifier, topInset ->
|
||||||
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
|
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
|
||||||
HeroHeader(
|
HeroHeader(
|
||||||
@@ -215,6 +236,7 @@ fun AlbumDetailScreen(
|
|||||||
onMore = onMore,
|
onMore = onMore,
|
||||||
topInset = topInset,
|
topInset = topInset,
|
||||||
controlsModifier = controlsModifier,
|
controlsModifier = controlsModifier,
|
||||||
|
nameMaxLines = Int.MAX_VALUE,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
content = { rowModifier ->
|
content = { rowModifier ->
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import com.subtracks.data.repo.LibraryRepository
|
|||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
@@ -35,13 +34,20 @@ class AlbumDetailViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
val album: Flow<Album?> = sourceId.flatMapLatest { libraryRepository.album(it, albumId) }
|
val album: StateFlow<Album?> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val songs: Flow<List<Song>> =
|
val songs: StateFlow<List<Song>> =
|
||||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) }
|
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
val discs: StateFlow<List<Disc>> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.subtracks.ui.library
|
package com.subtracks.ui.library
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -15,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan
|
|||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -37,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) }) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -27,12 +28,15 @@ import androidx.compose.foundation.layout.statusBars
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material.icons.rounded.Album
|
||||||
|
import androidx.compose.material.icons.rounded.Mic
|
||||||
import androidx.compose.material.icons.rounded.MoreHoriz
|
import androidx.compose.material.icons.rounded.MoreHoriz
|
||||||
import androidx.compose.material.icons.rounded.Shuffle
|
import androidx.compose.material.icons.rounded.Shuffle
|
||||||
import androidx.compose.material.icons.rounded.Star
|
import androidx.compose.material.icons.rounded.Star
|
||||||
@@ -52,40 +56,58 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Outline
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
import androidx.compose.ui.graphics.Shadow
|
import androidx.compose.ui.graphics.Shadow
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextMeasurer
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.ArtistAlbums
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.ArtistHeroArt
|
||||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
@@ -107,10 +129,16 @@ import com.subtracks.ui.theme.heroBarColor
|
|||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
import com.subtracks.ui.theme.rememberOverlaidNameBusy
|
import com.subtracks.ui.theme.rememberOverlaidNameBusy
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
private val ART_HEIGHT = 420.dp
|
private val ART_HEIGHT = 420.dp
|
||||||
private val TITLE_INSET = 16.dp
|
private val TITLE_INSET = 16.dp
|
||||||
@@ -121,6 +149,12 @@ private val SHUFFLE_ICON = 30.dp
|
|||||||
private val SHUFFLE_END = 22.dp
|
private val SHUFFLE_END = 22.dp
|
||||||
private val SHUFFLE_RESERVE = 86.dp
|
private val SHUFFLE_RESERVE = 86.dp
|
||||||
private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2
|
private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2
|
||||||
|
private val BIO_ACTION_RESERVE = 104.dp
|
||||||
|
private val BIO_ACTION_HEIGHT = 48.dp
|
||||||
|
private val BIO_ACTION_OFFSET = 16.dp
|
||||||
|
private val BIO_ACTION_END_OFFSET = 8.dp
|
||||||
|
private val BIO_TOP_GAP = 8.dp
|
||||||
|
private val BIO_BOTTOM_GAP = 8.dp
|
||||||
|
|
||||||
internal fun estimateScrollPx(
|
internal fun estimateScrollPx(
|
||||||
index: Int,
|
index: Int,
|
||||||
@@ -136,9 +170,18 @@ internal fun estimateScrollPx(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val FADE_LEAD = 24.dp
|
private val FADE_LEAD = 24.dp
|
||||||
|
private const val ARTIST_NAME_MAX_LINES = 6
|
||||||
private const val THEME_TRANSITION_MS = 100
|
private const val THEME_TRANSITION_MS = 100
|
||||||
private const val SCRIM_FADE_MS = 180
|
private const val SCRIM_FADE_MS = 180
|
||||||
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
|
||||||
|
internal const val ARTIST_BIO_MORE_TAG = "artistBioMore"
|
||||||
|
internal const val ARTIST_BIO_LESS_TAG = "artistBioLess"
|
||||||
|
|
||||||
|
private data class BioSnapshot(
|
||||||
|
val known: Boolean,
|
||||||
|
val present: Boolean,
|
||||||
|
val biography: String?,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ArtistDetailRoute(
|
fun ArtistDetailRoute(
|
||||||
@@ -152,11 +195,24 @@ fun ArtistDetailRoute(
|
|||||||
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
|
val artist by viewModel.artist.collectAsStateWithLifecycle()
|
||||||
val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null)
|
val artistName by viewModel.artistName.collectAsStateWithLifecycle()
|
||||||
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList())
|
val albums by viewModel.albums.collectAsStateWithLifecycle()
|
||||||
|
val bioSnapshots =
|
||||||
|
remember(viewModel) {
|
||||||
|
viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) }
|
||||||
|
}
|
||||||
|
val bioSnapshot by
|
||||||
|
bioSnapshots.collectAsStateWithLifecycle(
|
||||||
|
initialValue = BioSnapshot(known = false, present = false, biography = null),
|
||||||
|
)
|
||||||
|
val bioLoading by viewModel.bioLoading.collectAsStateWithLifecycle()
|
||||||
|
val bioUnavailable by viewModel.bioUnavailable.collectAsStateWithLifecycle()
|
||||||
|
val bioExpanded by viewModel.bioExpanded.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(artistId) { viewModel.refreshBio() }
|
||||||
val art by viewModel.art.collectAsStateWithLifecycle()
|
val art by viewModel.art.collectAsStateWithLifecycle()
|
||||||
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
||||||
|
val heroArt by viewModel.heroArt.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
@@ -187,10 +243,16 @@ fun ArtistDetailRoute(
|
|||||||
ArtistDetailScreen(
|
ArtistDetailScreen(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
artistName = artistName.orEmpty(),
|
artistName = artistName.orEmpty(),
|
||||||
isAlbumArtist = albums.any { it.artistId == artistId },
|
albums = albums ?: ArtistAlbums(),
|
||||||
albums = albums,
|
albumsLoaded = albums != null,
|
||||||
|
bio = bioSnapshot.biography,
|
||||||
|
bioLoading = bioLoading && !bioSnapshot.present,
|
||||||
|
bioUnavailable = bioUnavailable && !bioSnapshot.present,
|
||||||
|
bioPending = !bioSnapshot.known,
|
||||||
|
bioExpanded = bioExpanded,
|
||||||
art = art,
|
art = art,
|
||||||
artThumbnail = artThumbnail,
|
artThumbnail = artThumbnail,
|
||||||
|
heroArt = heroArt,
|
||||||
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
|
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -200,6 +262,8 @@ fun ArtistDetailRoute(
|
|||||||
onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } },
|
onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } },
|
||||||
starred = artist?.starred != null,
|
starred = artist?.starred != null,
|
||||||
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } },
|
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } },
|
||||||
|
onExpandBio = viewModel::expandBio,
|
||||||
|
onCollapseBio = viewModel::collapseBio,
|
||||||
onShuffle = viewModel::shuffle,
|
onShuffle = viewModel::shuffle,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -217,11 +281,18 @@ fun ArtistDetailRoute(
|
|||||||
@Composable
|
@Composable
|
||||||
fun ArtistDetailScreen(
|
fun ArtistDetailScreen(
|
||||||
artist: Artist?,
|
artist: Artist?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
artistName: String = "",
|
artistName: String = "",
|
||||||
isAlbumArtist: Boolean = false,
|
albums: ArtistAlbums = ArtistAlbums(),
|
||||||
albums: List<Album>,
|
albumsLoaded: Boolean = true,
|
||||||
|
bio: String? = null,
|
||||||
|
bioLoading: Boolean = false,
|
||||||
|
bioUnavailable: Boolean = false,
|
||||||
|
bioPending: Boolean = false,
|
||||||
|
bioExpanded: Boolean = false,
|
||||||
art: CoverArtRef?,
|
art: CoverArtRef?,
|
||||||
artThumbnail: CoverArtRef? = null,
|
artThumbnail: CoverArtRef? = null,
|
||||||
|
heroArt: ArtistArt? = null,
|
||||||
artwork: ArtworkColors? = null,
|
artwork: ArtworkColors? = null,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -231,10 +302,11 @@ fun ArtistDetailScreen(
|
|||||||
onMore: () -> Unit = {},
|
onMore: () -> Unit = {},
|
||||||
starred: Boolean = false,
|
starred: Boolean = false,
|
||||||
onToggleStar: (() -> Unit)? = null,
|
onToggleStar: (() -> Unit)? = null,
|
||||||
|
onExpandBio: () -> Unit = {},
|
||||||
|
onCollapseBio: () -> Unit = {},
|
||||||
onShuffle: () -> Unit = {},
|
onShuffle: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyGridState()
|
val listState = rememberRestorableLazyGridState(albumsLoaded)
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val lineHeight =
|
val lineHeight =
|
||||||
@@ -245,10 +317,31 @@ fun ArtistDetailScreen(
|
|||||||
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||||
val navBarBottom = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
val navBarBottom = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
||||||
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
|
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
|
||||||
val albumCount = albums.size.toLong()
|
val albumCount = albums.count.toLong()
|
||||||
val displayName = artistName.ifBlank { artist?.name.orEmpty() }
|
val displayName = artistName.ifBlank { artist?.name.orEmpty() }
|
||||||
val artistType = stringResource(if (isAlbumArtist) R.string.artist_type_album_artist else R.string.artist_type_artist)
|
val artistType =
|
||||||
val imageTitleTop = ART_HEIGHT - TITLE_INSET - lineHeight
|
stringResource(
|
||||||
|
if (albums.albumCredits.isNotEmpty()) R.string.artist_type_album_artist else R.string.artist_type_artist,
|
||||||
|
)
|
||||||
|
val albumCountText = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
|
||||||
|
val metaText = if (albumsLoaded) stringResource(R.string.artist_meta, artistType, albumCountText) else ""
|
||||||
|
val hasBio = !bio.isNullOrBlank()
|
||||||
|
val bioCollapsed = hasBio && !bioExpanded
|
||||||
|
val bioText =
|
||||||
|
when {
|
||||||
|
bioPending -> ""
|
||||||
|
hasBio -> bio.orEmpty()
|
||||||
|
bioLoading -> stringResource(R.string.artist_bio_loading)
|
||||||
|
bioUnavailable -> stringResource(R.string.artist_bio_unavailable)
|
||||||
|
else -> stringResource(R.string.artist_bio_empty)
|
||||||
|
}
|
||||||
|
var titleHeightPx by remember { mutableFloatStateOf(0f) }
|
||||||
|
val titleHeight = if (titleHeightPx > 0f) with(density) { titleHeightPx.toDp() } else lineHeight
|
||||||
|
val titleMaxLines =
|
||||||
|
((ART_HEIGHT - TITLE_INSET - barHeight - FADE_LEAD) / lineHeight)
|
||||||
|
.toInt()
|
||||||
|
.coerceIn(1, ARTIST_NAME_MAX_LINES)
|
||||||
|
val imageTitleTop = ART_HEIGHT - TITLE_INSET - titleHeight
|
||||||
val fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() }
|
val fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() }
|
||||||
val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() }
|
val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() }
|
||||||
val barFraction by remember(fadeStartPx, fadeEndPx) {
|
val barFraction by remember(fadeStartPx, fadeEndPx) {
|
||||||
@@ -260,8 +353,8 @@ fun ArtistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var imageHeightPx by remember { mutableFloatStateOf(0f) }
|
var imageHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
var albumHeightPx by remember { mutableFloatStateOf(0f) }
|
var albumHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||||
val albumRowGapPx = with(density) { ROW_GAP.toPx() }
|
val albumRowGapPx = with(density) { ROW_GAP.toPx() }
|
||||||
val scrollPx by remember {
|
val scrollPx by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
@@ -293,7 +386,9 @@ fun ArtistDetailScreen(
|
|||||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||||
val barHeightPx = with(density) { barHeight.toPx() }
|
val barHeightPx = with(density) { barHeight.toPx() }
|
||||||
val gradientScrollPx = scrollPx - imageHeightPx
|
// The gradient is anchored to the hero image, not the whole header, whose bio can expand.
|
||||||
|
val heroHeightPx = with(density) { ART_HEIGHT.toPx() }
|
||||||
|
val gradientScrollPx = scrollPx - heroHeightPx
|
||||||
val barColor = heroBarColor(effectiveArtwork, gradientScrollPx, barHeightPx, screenHeightPx)
|
val barColor = heroBarColor(effectiveArtwork, gradientScrollPx, barHeightPx, screenHeightPx)
|
||||||
HeroGradient(
|
HeroGradient(
|
||||||
colors = effectiveArtwork,
|
colors = effectiveArtwork,
|
||||||
@@ -315,11 +410,9 @@ fun ArtistDetailScreen(
|
|||||||
.onSizeChanged { imageHeightPx = it.height.toFloat() },
|
.onSizeChanged { imageHeightPx = it.height.toFloat() },
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxWidth()) {
|
Box(Modifier.fillMaxWidth()) {
|
||||||
CoverArt(
|
ArtistHeroArt(
|
||||||
ref = art,
|
art = heroArt ?: ArtistArt(image = art, thumbnail = artThumbnail),
|
||||||
name = displayName,
|
name = displayName,
|
||||||
thumbnailRef = artThumbnail,
|
|
||||||
showPlaceholder = art == null,
|
|
||||||
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
|
||||||
)
|
)
|
||||||
if (nameScrimAlpha > 0f) {
|
if (nameScrimAlpha > 0f) {
|
||||||
@@ -328,7 +421,7 @@ fun ArtistDetailScreen(
|
|||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(ART_HEIGHT * 0.4f)
|
.height(maxOf(ART_HEIGHT * 0.4f, titleHeight + TITLE_INSET * 2))
|
||||||
.graphicsLayer { alpha = nameScrimAlpha }
|
.graphicsLayer { alpha = nameScrimAlpha }
|
||||||
.background(
|
.background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
@@ -343,7 +436,13 @@ fun ArtistDetailScreen(
|
|||||||
style = imageNameStyle,
|
style = imageNameStyle,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
endReserve = SHUFFLE_RESERVE,
|
endReserve = SHUFFLE_RESERVE,
|
||||||
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(TITLE_INSET),
|
maxLines = titleMaxLines,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(TITLE_INSET)
|
||||||
|
.onSizeChanged { titleHeightPx = it.height.toFloat() },
|
||||||
)
|
)
|
||||||
FilledIconButton(
|
FilledIconButton(
|
||||||
onClick = onShuffle,
|
onClick = onShuffle,
|
||||||
@@ -365,95 +464,71 @@ fun ArtistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = TITLE_INSET),
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = TITLE_INSET),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = artistType,
|
text = metaText,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
// Hold the row at the button height even without the buttons, so the
|
|
||||||
// label-to-count gap is the same for every artist type.
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 48.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.fillMaxWidth().padding(end = SHUFFLE_RESERVE),
|
||||||
)
|
)
|
||||||
if (artist != null) {
|
ArtistBio(
|
||||||
IconButton(
|
text = bioText,
|
||||||
onClick = { onToggleStar?.invoke() },
|
color =
|
||||||
enabled = onToggleStar != null,
|
if (hasBio) {
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
|
|
||||||
contentDescription =
|
|
||||||
stringResource(
|
|
||||||
if (starred) R.string.actions_unstar else R.string.actions_star,
|
|
||||||
),
|
|
||||||
tint =
|
|
||||||
if (starred) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outline
|
||||||
},
|
},
|
||||||
)
|
collapsed = bioCollapsed,
|
||||||
}
|
expanded = hasBio && bioExpanded,
|
||||||
IconButton(onClick = onMore) {
|
artist = artist,
|
||||||
Icon(Icons.Rounded.MoreHoriz, contentDescription = stringResource(R.string.more_options))
|
starred = starred,
|
||||||
}
|
onToggleStar = onToggleStar,
|
||||||
}
|
onMore = onMore,
|
||||||
}
|
onExpand = onExpandBio,
|
||||||
}
|
onCollapse = onCollapseBio,
|
||||||
}
|
|
||||||
}
|
|
||||||
itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
|
|
||||||
val art = coverArt(album.coverArt, false)
|
|
||||||
val thumbnail = coverArt(album.coverArt, true)
|
|
||||||
PrefetchArtworkSeeds(thumbnail)
|
|
||||||
Column(
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier.padding(
|
||||||
.padding(
|
bottom = if (hasBio) BIO_BOTTOM_GAP else 0.dp,
|
||||||
start = if (index % 2 == 0) 16.dp else 0.dp,
|
),
|
||||||
end = if (index % 2 == 1) 16.dp else 0.dp,
|
|
||||||
).combinedClickable(
|
|
||||||
onClick = { onAlbumClick(album) },
|
|
||||||
onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id])) },
|
|
||||||
).onSizeChanged { albumHeightPx = it.height.toFloat() },
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
|
|
||||||
) {
|
|
||||||
CoverArt(
|
|
||||||
ref = art,
|
|
||||||
name = album.name,
|
|
||||||
thumbnailRef = thumbnail,
|
|
||||||
showPlaceholder = art == null,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
ListDownloadIndicator(
|
|
||||||
status = albumDownloads[album.id],
|
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
|
|
||||||
scrim = true,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
}
|
||||||
text = album.name,
|
}
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
if (albums.albumCredits.isNotEmpty()) {
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
maxLines = 1,
|
ArtistSectionHeader(stringResource(R.string.artist_section_albums), Icons.Rounded.Album)
|
||||||
overflow = TextOverflow.Ellipsis,
|
}
|
||||||
modifier = Modifier.padding(top = 6.dp),
|
itemsIndexed(albums.albumCredits, key = { _, album -> album.id }) { index, album ->
|
||||||
|
ArtistAlbumCard(
|
||||||
|
album = album,
|
||||||
|
index = index,
|
||||||
|
coverArt = coverArt,
|
||||||
|
downloadStatus = albumDownloads[album.id],
|
||||||
|
onClick = { onAlbumClick(album) },
|
||||||
|
onLongClick = { thumbnail ->
|
||||||
|
onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id]))
|
||||||
|
},
|
||||||
|
onSizeChanged = { albumHeightPx = it },
|
||||||
)
|
)
|
||||||
Text(
|
}
|
||||||
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
|
}
|
||||||
style = MaterialTheme.typography.bodySmall,
|
if (albums.appearances.isNotEmpty()) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
ArtistSectionHeader(stringResource(R.string.artist_section_appearances), Icons.Rounded.Mic, topGap = 16.dp)
|
||||||
|
}
|
||||||
|
itemsIndexed(albums.appearances, key = { _, album -> album.id }) { index, album ->
|
||||||
|
ArtistAlbumCard(
|
||||||
|
album = album,
|
||||||
|
index = index,
|
||||||
|
coverArt = coverArt,
|
||||||
|
downloadStatus = albumDownloads[album.id],
|
||||||
|
onClick = { onAlbumClick(album) },
|
||||||
|
onLongClick = { thumbnail ->
|
||||||
|
onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id]))
|
||||||
|
},
|
||||||
|
onSizeChanged = { albumHeightPx = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,12 +574,441 @@ fun ArtistDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberRestorableLazyGridState(contentReady: Boolean): LazyGridState {
|
||||||
|
val state = rememberLazyGridState()
|
||||||
|
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
|
||||||
|
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
|
||||||
|
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
|
||||||
|
LaunchedEffect(contentReady) {
|
||||||
|
if (contentReady && pending) {
|
||||||
|
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
|
||||||
|
state.scrollToItem(target, offset)
|
||||||
|
pending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
|
||||||
|
.collect { (currentIndex, currentOffset) ->
|
||||||
|
if (!pending) {
|
||||||
|
index = currentIndex
|
||||||
|
offset = currentOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the lines that overlap the action buttons are narrowed; later lines use the full width.
|
||||||
|
@Composable
|
||||||
|
private fun ArtistBio(
|
||||||
|
text: String,
|
||||||
|
color: Color,
|
||||||
|
collapsed: Boolean,
|
||||||
|
expanded: Boolean,
|
||||||
|
artist: Artist?,
|
||||||
|
starred: Boolean,
|
||||||
|
onToggleStar: (() -> Unit)?,
|
||||||
|
onMore: () -> Unit,
|
||||||
|
onExpand: () -> Unit,
|
||||||
|
onCollapse: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val style = MaterialTheme.typography.bodyMedium
|
||||||
|
val moreText = stringResource(R.string.artist_bio_more)
|
||||||
|
val lessText = stringResource(R.string.artist_bio_less)
|
||||||
|
BoxWithConstraints(modifier.fillMaxWidth().padding(top = BIO_TOP_GAP)) {
|
||||||
|
val fullWidth = with(density) { maxWidth.roundToPx() }
|
||||||
|
val reserve = with(density) { BIO_ACTION_RESERVE.roundToPx() }
|
||||||
|
val lineHeightPx =
|
||||||
|
remember(style) {
|
||||||
|
measurer.measure(AnnotatedString("X"), style, maxLines = 1).size.height
|
||||||
|
}.coerceAtLeast(1)
|
||||||
|
val reserveLines =
|
||||||
|
ceil(with(density) { (BIO_ACTION_HEIGHT - BIO_ACTION_OFFSET).toPx() } / lineHeightPx)
|
||||||
|
.toInt()
|
||||||
|
.coerceAtLeast(1)
|
||||||
|
val bioLines =
|
||||||
|
remember(
|
||||||
|
text,
|
||||||
|
fullWidth,
|
||||||
|
reserve,
|
||||||
|
reserveLines,
|
||||||
|
collapsed,
|
||||||
|
expanded,
|
||||||
|
moreText,
|
||||||
|
lessText,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
collapsed -> {
|
||||||
|
splitBioCollapsed(
|
||||||
|
measurer,
|
||||||
|
text,
|
||||||
|
style,
|
||||||
|
fullWidth,
|
||||||
|
reserve,
|
||||||
|
reserveLines,
|
||||||
|
moreText,
|
||||||
|
maxLines = 3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded -> {
|
||||||
|
splitBioExpanded(measurer, text, style, fullWidth, reserve, reserveLines, lessText)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
BioLines(
|
||||||
|
splitBio(measurer, text, style, fullWidth, reserve, reserveLines).map {
|
||||||
|
AnnotatedString(it.ifEmpty { " " })
|
||||||
|
},
|
||||||
|
more = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box {
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
bioLines.lines.forEachIndexed { index, line ->
|
||||||
|
val isControl = index == bioLines.lines.lastIndex && (bioLines.more || bioLines.less)
|
||||||
|
Text(
|
||||||
|
text = line,
|
||||||
|
style = style,
|
||||||
|
color = color,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier =
|
||||||
|
when {
|
||||||
|
isControl && bioLines.more -> Modifier.testTag(ARTIST_BIO_MORE_TAG).clickable(onClick = onExpand)
|
||||||
|
isControl && bioLines.less -> Modifier.testTag(ARTIST_BIO_LESS_TAG).clickable(onClick = onCollapse)
|
||||||
|
else -> Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.offset(x = BIO_ACTION_END_OFFSET, y = -BIO_ACTION_OFFSET),
|
||||||
|
) {
|
||||||
|
ArtistActions(artist, starred, onToggleStar, onMore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bioLineWidth(
|
||||||
|
index: Int,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
): Int = if (index < reserveLines) fullWidth - reserve else fullWidth
|
||||||
|
|
||||||
|
/** Splits text at every source line break, then wraps it to the width left by the actions. */
|
||||||
|
internal fun splitBio(
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
): List<String> {
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
text.split('\n').forEach { sourceLine ->
|
||||||
|
val words = sourceLine.trim().split(' ').filter { it.isNotEmpty() }
|
||||||
|
if (words.isEmpty()) {
|
||||||
|
lines += ""
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
var current = ""
|
||||||
|
for (word in words) {
|
||||||
|
val candidate = if (current.isEmpty()) word else "$current $word"
|
||||||
|
val available = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
|
||||||
|
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
|
||||||
|
current = candidate
|
||||||
|
} else if (current.isEmpty()) {
|
||||||
|
lines += candidate
|
||||||
|
} else {
|
||||||
|
lines += current
|
||||||
|
current = word
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.isNotEmpty()) lines += current
|
||||||
|
}
|
||||||
|
while (lines.isNotEmpty() && lines.last().isBlank()) lines.removeAt(lines.lastIndex)
|
||||||
|
return lines.ifEmpty { listOf("") }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun splitBioExpanded(
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
lessText: String,
|
||||||
|
): BioLines {
|
||||||
|
val lines = splitBio(measurer, text, style, fullWidth, reserve, reserveLines).mapTo(mutableListOf()) { AnnotatedString(it) }
|
||||||
|
appendBioControl(lines, lessText, measurer, style, fullWidth, reserve, reserveLines)
|
||||||
|
return BioLines(lines, more = false, less = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendBioControl(
|
||||||
|
lines: MutableList<AnnotatedString>,
|
||||||
|
label: String,
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
style: TextStyle,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
) {
|
||||||
|
while (lines.isNotEmpty() && lines.last().text.isBlank()) lines.removeAt(lines.lastIndex)
|
||||||
|
val control =
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(" ")
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(label) }
|
||||||
|
}
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
lines += control
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lastIndex = lines.lastIndex
|
||||||
|
val available = bioLineWidth(lastIndex, fullWidth, reserve, reserveLines)
|
||||||
|
val combined =
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(lines[lastIndex])
|
||||||
|
append(control)
|
||||||
|
}
|
||||||
|
if (measurer.measure(combined, style, maxLines = 1).size.width <= available) {
|
||||||
|
lines[lastIndex] = combined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val words = lines[lastIndex].text.split(' ').filter { it.isNotEmpty() }
|
||||||
|
if (words.size > 1) {
|
||||||
|
val tailLine =
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(words.last())
|
||||||
|
append(control)
|
||||||
|
}
|
||||||
|
if (measurer.measure(tailLine, style, maxLines = 1).size.width <= available) {
|
||||||
|
lines[lastIndex] = AnnotatedString(words.dropLast(1).joinToString(" "))
|
||||||
|
lines += tailLine
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines += control
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class BioLines(
|
||||||
|
val lines: List<AnnotatedString>,
|
||||||
|
val more: Boolean,
|
||||||
|
val less: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Collapses text to [maxLines] lines, appending a bold More only when it does not fit. */
|
||||||
|
internal fun splitBioCollapsed(
|
||||||
|
measurer: TextMeasurer,
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
fullWidth: Int,
|
||||||
|
reserve: Int,
|
||||||
|
reserveLines: Int,
|
||||||
|
moreText: String,
|
||||||
|
maxLines: Int = 2,
|
||||||
|
): BioLines {
|
||||||
|
val words =
|
||||||
|
text
|
||||||
|
.replace("\n\n", " ")
|
||||||
|
.replace('\n', ' ')
|
||||||
|
.split(' ')
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
if (words.isEmpty()) return BioLines(listOf(AnnotatedString("")), more = false)
|
||||||
|
val lines = mutableListOf<AnnotatedString>()
|
||||||
|
var index = 0
|
||||||
|
while (index < words.size && lines.size < maxLines - 1) {
|
||||||
|
var current = ""
|
||||||
|
while (index < words.size) {
|
||||||
|
val candidate = if (current.isEmpty()) words[index] else "$current ${words[index]}"
|
||||||
|
val available = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
|
||||||
|
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
|
||||||
|
current = candidate
|
||||||
|
index++
|
||||||
|
} else if (current.isEmpty()) {
|
||||||
|
current = words[index]
|
||||||
|
index++
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines += AnnotatedString(current)
|
||||||
|
}
|
||||||
|
if (index >= words.size) {
|
||||||
|
return BioLines(lines, more = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val remainingAvailable = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
|
||||||
|
val remaining = words.drop(index).joinToString(" ")
|
||||||
|
if (measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= remainingAvailable) {
|
||||||
|
lines += AnnotatedString(remaining)
|
||||||
|
return BioLines(lines, more = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val more =
|
||||||
|
buildAnnotatedString {
|
||||||
|
append("… ")
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(moreText) }
|
||||||
|
}
|
||||||
|
val moreWidth =
|
||||||
|
measurer.measure(more, style, maxLines = 1).size.width +
|
||||||
|
measurer.measure(AnnotatedString(" "), style, maxLines = 1).size.width
|
||||||
|
val lastAvailable = (bioLineWidth(lines.size, fullWidth, reserve, reserveLines) - moreWidth).coerceAtLeast(1)
|
||||||
|
var truncated = ""
|
||||||
|
while (index < words.size) {
|
||||||
|
val candidate = if (truncated.isEmpty()) words[index] else "$truncated ${words[index]}"
|
||||||
|
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= lastAvailable) {
|
||||||
|
truncated = candidate
|
||||||
|
index++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val lastLine =
|
||||||
|
buildAnnotatedString {
|
||||||
|
if (truncated.isNotEmpty()) append("$truncated ")
|
||||||
|
append("… ")
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(moreText) }
|
||||||
|
}
|
||||||
|
lines += lastLine
|
||||||
|
return BioLines(lines, more = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistSectionHeader(
|
||||||
|
text: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
topGap: Dp = 0.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp, end = 16.dp, top = topGap, bottom = 4.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = text.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
letterSpacing = 1.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistActions(
|
||||||
|
artist: Artist?,
|
||||||
|
starred: Boolean,
|
||||||
|
onToggleStar: (() -> Unit)?,
|
||||||
|
onMore: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (artist == null) return
|
||||||
|
IconButton(
|
||||||
|
onClick = { onToggleStar?.invoke() },
|
||||||
|
enabled = onToggleStar != null,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
|
||||||
|
contentDescription =
|
||||||
|
stringResource(
|
||||||
|
if (starred) R.string.actions_unstar else R.string.actions_star,
|
||||||
|
),
|
||||||
|
tint =
|
||||||
|
if (starred) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onMore) {
|
||||||
|
Icon(Icons.Rounded.MoreHoriz, contentDescription = stringResource(R.string.more_options))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistAlbumCard(
|
||||||
|
album: Album,
|
||||||
|
index: Int,
|
||||||
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
|
downloadStatus: ListDownloadStatus?,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: (CoverArtRef?) -> Unit,
|
||||||
|
onSizeChanged: (Float) -> Unit,
|
||||||
|
) {
|
||||||
|
val art = coverArt(album.coverArt, false)
|
||||||
|
val thumbnail = coverArt(album.coverArt, true)
|
||||||
|
PrefetchArtworkSeeds(thumbnail)
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(
|
||||||
|
start = if (index % 2 == 0) 16.dp else 0.dp,
|
||||||
|
end = if (index % 2 == 1) 16.dp else 0.dp,
|
||||||
|
).combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = { onLongClick(thumbnail) },
|
||||||
|
).onSizeChanged { onSizeChanged(it.height.toFloat()) },
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
|
||||||
|
) {
|
||||||
|
CoverArt(
|
||||||
|
ref = art,
|
||||||
|
name = album.name,
|
||||||
|
thumbnailRef = thumbnail,
|
||||||
|
showPlaceholder = art == null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
ListDownloadIndicator(
|
||||||
|
status = downloadStatus,
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
|
||||||
|
scrim = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = album.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistTitle(
|
private fun ArtistTitle(
|
||||||
text: String,
|
text: String,
|
||||||
style: TextStyle,
|
style: TextStyle,
|
||||||
color: Color,
|
color: Color,
|
||||||
endReserve: Dp,
|
endReserve: Dp,
|
||||||
|
maxLines: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val measurer = rememberTextMeasurer()
|
val measurer = rememberTextMeasurer()
|
||||||
@@ -512,7 +1016,10 @@ private fun ArtistTitle(
|
|||||||
BoxWithConstraints(modifier) {
|
BoxWithConstraints(modifier) {
|
||||||
val fullWidth = with(density) { maxWidth.roundToPx() }
|
val fullWidth = with(density) { maxWidth.roundToPx() }
|
||||||
val reserve = with(density) { endReserve.roundToPx() }
|
val reserve = with(density) { endReserve.roundToPx() }
|
||||||
val lines = remember(text, style, fullWidth, reserve) { splitTitle(measurer, text, style, fullWidth, reserve) }
|
val lines =
|
||||||
|
remember(text, style, fullWidth, reserve, maxLines) {
|
||||||
|
splitTitle(measurer, text, style, fullWidth, reserve, maxLines)
|
||||||
|
}
|
||||||
Column {
|
Column {
|
||||||
lines.forEachIndexed { index, line ->
|
lines.forEachIndexed { index, line ->
|
||||||
Text(
|
Text(
|
||||||
@@ -537,6 +1044,7 @@ internal fun splitTitle(
|
|||||||
style: TextStyle,
|
style: TextStyle,
|
||||||
fullWidth: Int,
|
fullWidth: Int,
|
||||||
reserve: Int,
|
reserve: Int,
|
||||||
|
maxLines: Int = ARTIST_NAME_MAX_LINES,
|
||||||
): List<String> {
|
): List<String> {
|
||||||
if (text.isBlank()) return listOf(text)
|
if (text.isBlank()) return listOf(text)
|
||||||
if (measurer.measure(AnnotatedString(text), style, maxLines = 1).size.width <= fullWidth - reserve) {
|
if (measurer.measure(AnnotatedString(text), style, maxLines = 1).size.width <= fullWidth - reserve) {
|
||||||
@@ -544,18 +1052,36 @@ internal fun splitTitle(
|
|||||||
}
|
}
|
||||||
val words = text.split(' ').filter { it.isNotEmpty() }
|
val words = text.split(' ').filter { it.isNotEmpty() }
|
||||||
if (words.size <= 1) return listOf(text)
|
if (words.size <= 1) return listOf(text)
|
||||||
var count = 0
|
val lines = mutableListOf<String>()
|
||||||
|
var index = 0
|
||||||
|
while (index < words.size && lines.size < maxLines) {
|
||||||
|
val remaining = words.subList(index, words.size).joinToString(" ")
|
||||||
|
if (lines.size == maxLines - 1 ||
|
||||||
|
measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= fullWidth - reserve
|
||||||
|
) {
|
||||||
|
lines.add(remaining)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var end = index
|
||||||
var acc = ""
|
var acc = ""
|
||||||
for (i in words.indices) {
|
while (end < words.size) {
|
||||||
val candidate = if (acc.isEmpty()) words[i] else "$acc ${words[i]}"
|
val candidate = if (acc.isEmpty()) words[end] else "$acc ${words[end]}"
|
||||||
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
|
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
|
||||||
acc = candidate
|
acc = candidate
|
||||||
count = i + 1
|
end++
|
||||||
} else {
|
} else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (count >= words.size) count = words.size - 1
|
if (end == index) {
|
||||||
if (count < 1) count = 1
|
acc = words[index]
|
||||||
return listOf(words.take(count).joinToString(" "), words.drop(count).joinToString(" "))
|
end = index + 1
|
||||||
|
} else if (end >= words.size && end - index > 1) {
|
||||||
|
end -= 1
|
||||||
|
acc = words.subList(index, end).joinToString(" ")
|
||||||
|
}
|
||||||
|
lines.add(acc)
|
||||||
|
index = end
|
||||||
|
}
|
||||||
|
return lines
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
.flatMapLatest { id ->
|
||||||
combine(
|
combine(
|
||||||
libraryRepository.artist(id, artistId),
|
libraryRepository.artist(id, artistId),
|
||||||
libraryRepository.artistName(id, artistId),
|
libraryRepository.artistName(id, artistId),
|
||||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
) { 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
|
||||||
@@ -69,7 +74,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
@@ -91,12 +95,15 @@ import androidx.compose.ui.layout.layout
|
|||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.Constraints
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
@@ -117,6 +124,7 @@ import com.subtracks.data.prefs.PlaylistSort
|
|||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
@@ -243,6 +251,7 @@ fun LibraryRoute(
|
|||||||
var previousTabName by remember { mutableStateOf(selectedTab.name) }
|
var previousTabName by remember { mutableStateOf(selectedTab.name) }
|
||||||
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
|
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
|
||||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||||
|
val refreshing by viewModel.refreshing.collectAsStateWithLifecycle()
|
||||||
val listTab = selectedTab.listTab()
|
val listTab = selectedTab.listTab()
|
||||||
LaunchedEffect(selectedTab) {
|
LaunchedEffect(selectedTab) {
|
||||||
if (previousTab != selectedTab) {
|
if (previousTab != selectedTab) {
|
||||||
@@ -270,6 +279,7 @@ fun LibraryRoute(
|
|||||||
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
||||||
val downloads = downloadsState?.value ?: emptyMap()
|
val downloads = downloadsState?.value ?: emptyMap()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||||
val itemActions =
|
val itemActions =
|
||||||
@@ -296,19 +306,24 @@ fun LibraryRoute(
|
|||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
|
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
|
||||||
syncing = syncing,
|
syncing = syncing,
|
||||||
|
refreshing = refreshing,
|
||||||
bottomInset = bottomInset,
|
bottomInset = bottomInset,
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
albums = viewModel.albums,
|
albums = viewModel.albums,
|
||||||
artists = viewModel.artists,
|
artists = viewModel.artists,
|
||||||
playlists = viewModel.playlists,
|
playlists = viewModel.playlists,
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
onSync = viewModel::sync,
|
onSync = viewModel::sync,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
onItemLongClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
contextMenuHost?.show(it, itemActions)
|
||||||
|
},
|
||||||
listQuery = displayQuery,
|
listQuery = displayQuery,
|
||||||
resetKeys = resetKeys,
|
resetKeys = resetKeys,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
@@ -386,11 +401,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 +427,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 +435,16 @@ fun LibraryScreen(
|
|||||||
)
|
)
|
||||||
var showOptions by rememberSaveable { mutableStateOf(false) }
|
var showOptions by rememberSaveable { mutableStateOf(false) }
|
||||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var topRequest by remember { mutableIntStateOf(0) }
|
||||||
val searchFocus = remember { FocusRequester() }
|
val searchFocus = remember { FocusRequester() }
|
||||||
|
val closeSearch = {
|
||||||
|
searchActive = false
|
||||||
|
onSearchChange("")
|
||||||
|
}
|
||||||
|
|
||||||
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
||||||
DismissOnRequest(dismissals) { showOptions = false }
|
DismissOnRequest(dismissals) { showOptions = false }
|
||||||
|
BackHandler(enabled = searchActive, onBack = closeSearch)
|
||||||
|
|
||||||
LaunchedEffect(pagerState) {
|
LaunchedEffect(pagerState) {
|
||||||
snapshotFlow { pagerState.settledPage }.collect { page ->
|
snapshotFlow { pagerState.settledPage }.collect { page ->
|
||||||
@@ -440,14 +463,8 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissSearch() {
|
|
||||||
if (searchActive) {
|
|
||||||
searchActive = false
|
|
||||||
onSearchChange("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||||
val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
|
val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
|
||||||
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
|
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
|
||||||
@@ -470,15 +487,16 @@ fun LibraryScreen(
|
|||||||
) { page ->
|
) { page ->
|
||||||
val pageTab = LibraryTab.entries[page]
|
val pageTab = LibraryTab.entries[page]
|
||||||
val resetKey = resetKeys[pageTab]
|
val resetKey = resetKeys[pageTab]
|
||||||
|
val scrollToTopKey = if (pageTab == selectedTab) topRequest else null
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = syncing,
|
isRefreshing = refreshing,
|
||||||
onRefresh = onSync,
|
onRefresh = onSync,
|
||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
indicator = {
|
indicator = {
|
||||||
PullToRefreshDefaults.Indicator(
|
PullToRefreshDefaults.Indicator(
|
||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
isRefreshing = syncing,
|
isRefreshing = refreshing,
|
||||||
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
|
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
|
||||||
containerColor = headerColor,
|
containerColor = headerColor,
|
||||||
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
|
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
|
||||||
@@ -497,7 +515,7 @@ fun LibraryScreen(
|
|||||||
coverArt,
|
coverArt,
|
||||||
listBottomInset,
|
listBottomInset,
|
||||||
{
|
{
|
||||||
dismissSearch()
|
focusManager.clearFocus()
|
||||||
onAlbumClick(it)
|
onAlbumClick(it)
|
||||||
},
|
},
|
||||||
onLongClick = onItemLongClick,
|
onLongClick = onItemLongClick,
|
||||||
@@ -507,6 +525,7 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = albumDownloads,
|
downloadStatuses = albumDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,7 +535,7 @@ fun LibraryScreen(
|
|||||||
coverArt,
|
coverArt,
|
||||||
listBottomInset,
|
listBottomInset,
|
||||||
{
|
{
|
||||||
dismissSearch()
|
focusManager.clearFocus()
|
||||||
onArtistClick(it)
|
onArtistClick(it)
|
||||||
},
|
},
|
||||||
onLongClick = onItemLongClick,
|
onLongClick = onItemLongClick,
|
||||||
@@ -526,6 +545,8 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = artistDownloads,
|
downloadStatuses = artistDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
|
artistArt = artistArt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +556,7 @@ fun LibraryScreen(
|
|||||||
coverArt,
|
coverArt,
|
||||||
listBottomInset,
|
listBottomInset,
|
||||||
{
|
{
|
||||||
dismissSearch()
|
focusManager.clearFocus()
|
||||||
onPlaylistClick(it)
|
onPlaylistClick(it)
|
||||||
},
|
},
|
||||||
onLongClick = onItemLongClick,
|
onLongClick = onItemLongClick,
|
||||||
@@ -545,6 +566,7 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = playlistDownloads,
|
downloadStatuses = playlistDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,11 +592,12 @@ fun LibraryScreen(
|
|||||||
) {
|
) {
|
||||||
LibraryTabs(
|
LibraryTabs(
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = { tab ->
|
||||||
|
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
||||||
|
},
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
offline = offline,
|
|
||||||
onExitOffline = onExitOffline,
|
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
|
syncing = syncing,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,10 +606,7 @@ fun LibraryScreen(
|
|||||||
SearchField(
|
SearchField(
|
||||||
value = search,
|
value = search,
|
||||||
onValueChange = onSearchChange,
|
onValueChange = onSearchChange,
|
||||||
onClose = {
|
onClose = closeSearch,
|
||||||
searchActive = false
|
|
||||||
onSearchChange("")
|
|
||||||
},
|
|
||||||
focusRequester = searchFocus,
|
focusRequester = searchFocus,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -597,11 +617,29 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!searchActive) {
|
if (!searchActive) {
|
||||||
|
val fabContainerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
val fabContentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground
|
||||||
|
if (offline) {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onExitOffline,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(start = 16.dp, bottom = tabBarHeight + 16.dp)
|
||||||
|
.size(48.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.CloudOff,
|
||||||
|
contentDescription = stringResource(R.string.offline_mode_status),
|
||||||
|
modifier = Modifier.size(24.dp).offset(y = (-0.75).dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
val searchFab = selectedTab == LibraryTab.Home
|
val searchFab = selectedTab == LibraryTab.Home
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
||||||
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
containerColor = fabContainerColor,
|
||||||
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground,
|
contentColor = fabContentColor,
|
||||||
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
||||||
) {
|
) {
|
||||||
@@ -620,10 +658,16 @@ 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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -631,6 +675,7 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showOptions) {
|
if (showOptions) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
@@ -684,7 +729,7 @@ private fun ListOptionsSheet(
|
|||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
@@ -806,6 +851,7 @@ private fun ListOptionsSheet(
|
|||||||
|
|
||||||
private val BOTTOM_CLEARANCE = 80.dp
|
private val BOTTOM_CLEARANCE = 80.dp
|
||||||
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
|
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
|
||||||
|
private val TAB_STRIP_LEADING_GAP = 8.dp
|
||||||
private val TAB_ICON_SIZE = 24.dp
|
private val TAB_ICON_SIZE = 24.dp
|
||||||
private val TAB_VERTICAL_PADDING = 6.dp
|
private val TAB_VERTICAL_PADDING = 6.dp
|
||||||
private val TOUCH_TARGET = 48.dp
|
private val TOUCH_TARGET = 48.dp
|
||||||
@@ -831,18 +877,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,18 +921,61 @@ 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),
|
||||||
) {
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.onSizeChanged { viewportWidth = it.width }
|
||||||
|
.horizontalScroll(scrollState)
|
||||||
|
.onSizeChanged { contentWidth = it.width },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.width(TAB_STRIP_LEADING_GAP))
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.drawBehind {
|
Modifier
|
||||||
if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) {
|
.drawBehind {
|
||||||
|
if (current != null &&
|
||||||
|
indicatorLeft != null &&
|
||||||
|
indicatorRight != null &&
|
||||||
|
indicatorRight > indicatorLeft
|
||||||
|
) {
|
||||||
val height = tabContentHeightPx.coerceAtMost(current.height)
|
val height = tabContentHeightPx.coerceAtMost(current.height)
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = indicatorColor,
|
color = indicatorColor,
|
||||||
@@ -897,7 +987,6 @@ internal fun LibraryTabs(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
@@ -920,22 +1009,23 @@ internal fun LibraryTabs(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
}
|
||||||
if (offline) {
|
|
||||||
IconButton(onClick = onExitOffline) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Rounded.CloudOff,
|
|
||||||
contentDescription = stringResource(R.string.offline_mode_status),
|
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onOpenSettings) {
|
IconButton(onClick = onOpenSettings) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Settings,
|
imageVector = Icons.Rounded.Settings,
|
||||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
)
|
)
|
||||||
|
if (syncing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import com.subtracks.data.repo.SourceRepository
|
|||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
import com.subtracks.data.sync.SyncStatus
|
import com.subtracks.data.sync.SyncStatus
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
|
import com.subtracks.ui.components.artistArt
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -117,7 +119,12 @@ class LibraryViewModel(
|
|||||||
|
|
||||||
val syncing: StateFlow<Boolean> =
|
val syncing: StateFlow<Boolean> =
|
||||||
syncManager.status
|
syncManager.status
|
||||||
.map { it == SyncStatus.Running }
|
.map { it is SyncStatus.Running }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
|
val refreshing: StateFlow<Boolean> =
|
||||||
|
syncManager.status
|
||||||
|
.map { it is SyncStatus.Running && !it.silent }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
@@ -206,7 +213,14 @@ class LibraryViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
fun sync() = syncManager.requestSync()
|
fun artistArt(
|
||||||
|
artist: Artist,
|
||||||
|
forIcon: Boolean = true,
|
||||||
|
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||||
|
|
||||||
|
fun sync() {
|
||||||
|
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
|
internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ fun PlaylistDetailRoute(
|
|||||||
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
||||||
playbackController: PlaybackController = koinInject(),
|
playbackController: PlaybackController = koinInject(),
|
||||||
) {
|
) {
|
||||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
val playlist by viewModel.playlist.collectAsStateWithLifecycle()
|
||||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
@@ -120,6 +120,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 +132,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),
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ class PlaylistDetailViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
val playlist: Flow<Playlist?> = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
val playlist: StateFlow<Playlist?> =
|
||||||
|
sourceId
|
||||||
|
.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
val songs: Flow<PagingData<PlaylistSongItem>> =
|
val songs: Flow<PagingData<PlaylistSongItem>> =
|
||||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
|||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
|
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -44,6 +45,7 @@ fun PlaylistsContent(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
bottomInset: Dp,
|
bottomInset: Dp,
|
||||||
onPlaylistClick: (Playlist) -> Unit,
|
onPlaylistClick: (Playlist) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onLongClick: (MenuTarget) -> Unit = {},
|
onLongClick: (MenuTarget) -> Unit = {},
|
||||||
filtered: Boolean = false,
|
filtered: Boolean = false,
|
||||||
onClearFilters: () -> Unit = {},
|
onClearFilters: () -> Unit = {},
|
||||||
@@ -51,7 +53,7 @@ fun PlaylistsContent(
|
|||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
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 -> {
|
||||||
@@ -74,6 +76,7 @@ fun PlaylistsContent(
|
|||||||
else -> {
|
else -> {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||||
|
ScrollToTopOnRequest(scrollToTopKey, listState)
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import com.subtracks.data.model.DownloadStatus
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
|
import com.subtracks.ui.components.artistSubtitle
|
||||||
|
|
||||||
private val TRACK_COLUMN_WIDTH = 24.dp
|
private val TRACK_COLUMN_WIDTH = 24.dp
|
||||||
private val TIME_COLUMN_WIDTH = 36.dp
|
private val TIME_COLUMN_WIDTH = 36.dp
|
||||||
@@ -93,6 +94,7 @@ private fun buildVectorPath(vector: ImageVector): Path {
|
|||||||
@Composable
|
@Composable
|
||||||
fun SongRow(
|
fun SongRow(
|
||||||
song: Song,
|
song: Song,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
coverArtId: String? = null,
|
coverArtId: String? = null,
|
||||||
coverArt: ((String?, Boolean) -> CoverArtRef?)? = null,
|
coverArt: ((String?, Boolean) -> CoverArtRef?)? = null,
|
||||||
isPlaying: Boolean = false,
|
isPlaying: Boolean = false,
|
||||||
@@ -100,7 +102,6 @@ fun SongRow(
|
|||||||
durationSeconds: Long? = null,
|
durationSeconds: Long? = null,
|
||||||
download: SongDownload? = null,
|
download: SongDownload? = null,
|
||||||
trailingContent: (@Composable () -> Unit)? = null,
|
trailingContent: (@Composable () -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -146,7 +147,7 @@ fun SongRow(
|
|||||||
},
|
},
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(
|
Text(
|
||||||
text = song.artist.orEmpty().ifEmpty { "\u00A0" },
|
text = song.artistSubtitle().orEmpty().ifEmpty { "\u00A0" },
|
||||||
color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import com.subtracks.R
|
|||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.playback.PlaybackState
|
import com.subtracks.playback.PlaybackState
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
|
import com.subtracks.ui.components.artistSubtitle
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.ArtworkTheme
|
import com.subtracks.ui.theme.ArtworkTheme
|
||||||
import com.subtracks.ui.theme.playerSurfaceColor
|
import com.subtracks.ui.theme.playerSurfaceColor
|
||||||
@@ -60,10 +61,10 @@ fun MiniPlayer(
|
|||||||
onExpand: () -> Unit,
|
onExpand: () -> Unit,
|
||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
progressInset: Boolean = false,
|
progressInset: Boolean = false,
|
||||||
onExpandDrag: (Float) -> Unit = {},
|
onExpandDrag: (Float) -> Unit = {},
|
||||||
onExpandRelease: (Float) -> Unit = {},
|
onExpandRelease: (Float) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
val item = state.item ?: return
|
val item = state.item ?: return
|
||||||
var dragUpPx by remember { mutableFloatStateOf(0f) }
|
var dragUpPx by remember { mutableFloatStateOf(0f) }
|
||||||
@@ -139,7 +140,7 @@ fun MiniPlayer(
|
|||||||
modifier = Modifier.basicMarquee(),
|
modifier = Modifier.basicMarquee(),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = item.artist ?: item.album.orEmpty(),
|
text = artistSubtitle(item.artist, item.artists) ?: item.album.orEmpty(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.keepScreenOn
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
@@ -77,6 +77,7 @@ import com.subtracks.R
|
|||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
@@ -84,10 +85,12 @@ import com.subtracks.playback.PlaybackController
|
|||||||
import com.subtracks.playback.PlaybackState
|
import com.subtracks.playback.PlaybackState
|
||||||
import com.subtracks.playback.QueueItem
|
import com.subtracks.playback.QueueItem
|
||||||
import com.subtracks.playback.RepeatMode
|
import com.subtracks.playback.RepeatMode
|
||||||
|
import com.subtracks.ui.components.ArtistChooserSheet
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.artistSubtitle
|
||||||
import com.subtracks.ui.components.prefetchArtworkRatio
|
import com.subtracks.ui.components.prefetchArtworkRatio
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||||
@@ -111,6 +114,10 @@ private const val SWIPE_SETTLE_MS = 180
|
|||||||
private const val SWIPE_EDGE_RESISTANCE = 0.3f
|
private const val SWIPE_EDGE_RESISTANCE = 0.3f
|
||||||
private const val HAND_BACK_TIMEOUT_MS = 750L
|
private const val HAND_BACK_TIMEOUT_MS = 750L
|
||||||
|
|
||||||
|
// The strip is unclipped, so this spacing is what keeps the neighbours off-screen, and it has to
|
||||||
|
// clear the content inset plus a tile's shadow. It doubles as the gap between tiles while dragging.
|
||||||
|
private val TILE_GAP = 40.dp
|
||||||
|
|
||||||
data class NowPlayingArt(
|
data class NowPlayingArt(
|
||||||
val id: String?,
|
val id: String?,
|
||||||
val ref: CoverArtRef?,
|
val ref: CoverArtRef?,
|
||||||
@@ -182,20 +189,24 @@ fun NowPlayingRoute(
|
|||||||
onQueue: () -> Unit,
|
onQueue: () -> Unit,
|
||||||
onViewAlbum: (String) -> Unit,
|
onViewAlbum: (String) -> Unit,
|
||||||
onViewArtist: (String) -> Unit,
|
onViewArtist: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
contextMenuHost: ContextMenuHost? = null,
|
contextMenuHost: ContextMenuHost? = null,
|
||||||
setStar: (StarType, String, Boolean) -> Unit,
|
setStar: (StarType, String, Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
controller: PlaybackController = koinInject(),
|
controller: PlaybackController = koinInject(),
|
||||||
libraryRepository: LibraryRepository = koinInject(),
|
libraryRepository: LibraryRepository = koinInject(),
|
||||||
downloadRepository: DownloadRepository = koinInject(),
|
downloadRepository: DownloadRepository = koinInject(),
|
||||||
|
userPreferences: UserPreferences = koinInject(),
|
||||||
) {
|
) {
|
||||||
val state by controller.state.collectAsStateWithLifecycle()
|
val state by controller.state.collectAsStateWithLifecycle()
|
||||||
|
val keepScreenOn by remember { userPreferences.keepScreenOn() }.collectAsStateWithLifecycle(initialValue = false)
|
||||||
val positionMs by controller.positionMs.collectAsStateWithLifecycle()
|
val positionMs by controller.positionMs.collectAsStateWithLifecycle()
|
||||||
val sourceId = state.context?.sourceId
|
val sourceId = state.context?.sourceId
|
||||||
val songId = state.item?.id
|
val songId = state.item?.id
|
||||||
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
||||||
val download = songId?.let { downloads[it] }
|
val download = songId?.let { downloads[it] }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
var showArtistChooser by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(songId) { showArtistChooser = false }
|
||||||
val song by
|
val song by
|
||||||
produceState<Song?>(null, sourceId, songId) {
|
produceState<Song?>(null, sourceId, songId) {
|
||||||
if (sourceId != null && songId != null) {
|
if (sourceId != null && songId != null) {
|
||||||
@@ -234,7 +245,7 @@ fun NowPlayingRoute(
|
|||||||
name = item.title,
|
name = item.title,
|
||||||
)
|
)
|
||||||
var adjacent by remember { mutableStateOf(AdjacentArt()) }
|
var adjacent by remember { mutableStateOf(AdjacentArt()) }
|
||||||
LaunchedEffect(state.item?.id, state.layout) {
|
LaunchedEffect(state.item?.id, state.layout, state.repeat) {
|
||||||
if (state.item == null) {
|
if (state.item == null) {
|
||||||
adjacent = AdjacentArt()
|
adjacent = AdjacentArt()
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
@@ -265,12 +276,32 @@ fun NowPlayingRoute(
|
|||||||
onRepeat = controller::cycleRepeat,
|
onRepeat = controller::cycleRepeat,
|
||||||
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
|
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
|
||||||
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
|
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
|
||||||
onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } },
|
onArtistClick =
|
||||||
|
song
|
||||||
|
?.takeIf { it.artistRefs.isNotEmpty() }
|
||||||
|
?.let {
|
||||||
|
{
|
||||||
|
val artists = it.artistRefs
|
||||||
|
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
|
||||||
|
}
|
||||||
|
},
|
||||||
starred = song?.starred != null,
|
starred = song?.starred != null,
|
||||||
onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } },
|
onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } },
|
||||||
onSeek = controller::seekTo,
|
onSeek = controller::seekTo,
|
||||||
|
keepScreenOn = keepScreenOn,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (showArtistChooser) {
|
||||||
|
ArtistChooserSheet(
|
||||||
|
artists = song?.artistRefs.orEmpty(),
|
||||||
|
onArtistClick = { artist ->
|
||||||
|
showArtistChooser = false
|
||||||
|
onViewArtist(artist.id)
|
||||||
|
},
|
||||||
|
onDismiss = { showArtistChooser = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -286,6 +317,8 @@ fun NowPlayingScreen(
|
|||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
keepScreenOn: Boolean = false,
|
||||||
onSkipPrevious: () -> Unit = onPrevious,
|
onSkipPrevious: () -> Unit = onPrevious,
|
||||||
onShuffle: () -> Unit = {},
|
onShuffle: () -> Unit = {},
|
||||||
onRepeat: () -> Unit = {},
|
onRepeat: () -> Unit = {},
|
||||||
@@ -298,7 +331,6 @@ fun NowPlayingScreen(
|
|||||||
thumbnailRef: CoverArtRef? = null,
|
thumbnailRef: CoverArtRef? = null,
|
||||||
previousArt: NowPlayingArt? = null,
|
previousArt: NowPlayingArt? = null,
|
||||||
nextArt: NowPlayingArt? = null,
|
nextArt: NowPlayingArt? = null,
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
val playButtonSize = 90.dp
|
val playButtonSize = 90.dp
|
||||||
val playCircleDiameter = playButtonSize * 20f / 24f
|
val playCircleDiameter = playButtonSize * 20f / 24f
|
||||||
@@ -329,6 +361,7 @@ fun NowPlayingScreen(
|
|||||||
val strip = frozenStrip ?: liveStrip
|
val strip = frozenStrip ?: liveStrip
|
||||||
val canGoNext = strip.getOrNull(2) != null
|
val canGoNext = strip.getOrNull(2) != null
|
||||||
val canGoPrevious = strip.getOrNull(0) != null
|
val canGoPrevious = strip.getOrNull(0) != null
|
||||||
|
val stridePx = coverWidthPx + with(LocalDensity.current) { TILE_GAP.toPx() }
|
||||||
|
|
||||||
fun animateSwipe(target: Float) {
|
fun animateSwipe(target: Float) {
|
||||||
settleJob?.cancel()
|
settleJob?.cancel()
|
||||||
@@ -340,7 +373,7 @@ fun NowPlayingScreen(
|
|||||||
|
|
||||||
fun commitSwipe(direction: Int) {
|
fun commitSwipe(direction: Int) {
|
||||||
frozenStrip = rotateStrip(strip, direction)
|
frozenStrip = rotateStrip(strip, direction)
|
||||||
swipeOffsetX += direction * coverWidthPx
|
swipeOffsetX += direction * stridePx
|
||||||
if (direction > 0) onNext() else onSkipPrevious()
|
if (direction > 0) onNext() else onSkipPrevious()
|
||||||
animateSwipe(0f)
|
animateSwipe(0f)
|
||||||
}
|
}
|
||||||
@@ -362,7 +395,12 @@ fun NowPlayingScreen(
|
|||||||
|
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||||
ArtworkTheme(artwork) {
|
ArtworkTheme(artwork) {
|
||||||
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
Box(
|
||||||
|
modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.then(if (keepScreenOn) Modifier.keepScreenOn() else Modifier),
|
||||||
|
) {
|
||||||
HeroGradient(
|
HeroGradient(
|
||||||
colors = artwork,
|
colors = artwork,
|
||||||
scrollPx = { 0f },
|
scrollPx = { 0f },
|
||||||
@@ -410,13 +448,14 @@ fun NowPlayingScreen(
|
|||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
// Deliberately unclipped: a tile that fills the width would lose its drop
|
||||||
|
// shadow. TILE_GAP keeps the neighbours off-screen instead.
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 2.dp)
|
.padding(top = 2.dp)
|
||||||
.clipToBounds()
|
|
||||||
.onSizeChanged { coverWidthPx = it.width.toFloat() }
|
.onSizeChanged { coverWidthPx = it.width.toFloat() }
|
||||||
.draggable(
|
.draggable(
|
||||||
orientation = Orientation.Horizontal,
|
orientation = Orientation.Horizontal,
|
||||||
@@ -428,7 +467,7 @@ fun NowPlayingScreen(
|
|||||||
delta < 0 && !canGoNext -> delta * SWIPE_EDGE_RESISTANCE
|
delta < 0 && !canGoNext -> delta * SWIPE_EDGE_RESISTANCE
|
||||||
else -> delta
|
else -> delta
|
||||||
}
|
}
|
||||||
swipeOffsetX = (swipeOffsetX + resisted).coerceIn(-coverWidthPx, coverWidthPx)
|
swipeOffsetX = (swipeOffsetX + resisted).coerceIn(-stridePx, stridePx)
|
||||||
},
|
},
|
||||||
onDragStarted = {
|
onDragStarted = {
|
||||||
dragging = true
|
dragging = true
|
||||||
@@ -484,7 +523,7 @@ fun NowPlayingScreen(
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
translationX = (index - 1) * coverWidthPx + swipeOffsetX
|
translationX = (index - 1) * stridePx + swipeOffsetX
|
||||||
}.then(
|
}.then(
|
||||||
when (index) {
|
when (index) {
|
||||||
0 -> Modifier.testTag(NOW_PLAYING_PREVIOUS_TAG)
|
0 -> Modifier.testTag(NOW_PLAYING_PREVIOUS_TAG)
|
||||||
@@ -538,7 +577,10 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
|
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
|
||||||
Text(
|
Text(
|
||||||
text = state.item?.artist.orEmpty(),
|
text =
|
||||||
|
state.item
|
||||||
|
?.let { artistSubtitle(it.artist, it.artists) }
|
||||||
|
.orEmpty(),
|
||||||
style = subtitleStyle,
|
style = subtitleStyle,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import androidx.compose.material3.TopAppBar
|
|||||||
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.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -106,12 +107,14 @@ class QueueViewModel(
|
|||||||
var ready by mutableStateOf(false)
|
var ready by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var initialIndex by mutableStateOf(0)
|
var initialIndex by mutableIntStateOf(0)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var generation by mutableStateOf(0)
|
var generation by mutableIntStateOf(0)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
private var reorderPending = false
|
||||||
|
|
||||||
private var nextId = 0L
|
private var nextId = 0L
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@@ -248,13 +251,20 @@ class QueueViewModel(
|
|||||||
fun move(
|
fun move(
|
||||||
from: Long,
|
from: Long,
|
||||||
to: Long,
|
to: Long,
|
||||||
) {
|
): Boolean {
|
||||||
if (from == to) return
|
if (from == to) return false
|
||||||
|
if (reorderPending) return false
|
||||||
|
reorderPending = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
playbackController.move(from, to)
|
playbackController.move(from, to)
|
||||||
mutex.withLock { reconcileLocked() }
|
mutex.withLock { reconcileLocked() }
|
||||||
|
} finally {
|
||||||
|
reorderPending = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
fun remove(position: Long) {
|
fun remove(position: Long) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -330,7 +340,7 @@ fun QueueScreen(
|
|||||||
onPlay: (Long) -> Unit,
|
onPlay: (Long) -> Unit,
|
||||||
onRemove: (Long) -> Unit,
|
onRemove: (Long) -> Unit,
|
||||||
onReorder: (Int, Int) -> Unit,
|
onReorder: (Int, Int) -> Unit,
|
||||||
onMove: (Long, Long) -> Unit,
|
onMove: (Long, Long) -> Boolean,
|
||||||
onLoadOlder: () -> Unit,
|
onLoadOlder: () -> Unit,
|
||||||
onLoadNewer: () -> Unit,
|
onLoadNewer: () -> Unit,
|
||||||
onUndo: () -> Unit,
|
onUndo: () -> Unit,
|
||||||
@@ -346,7 +356,6 @@ fun QueueScreen(
|
|||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var dragId by remember { mutableStateOf<Long?>(null) }
|
var dragId by remember { mutableStateOf<Long?>(null) }
|
||||||
var dragFrom by remember { mutableStateOf<Long?>(null) }
|
|
||||||
val undoLabel = stringResource(R.string.undo)
|
val undoLabel = stringResource(R.string.undo)
|
||||||
val queueReordered = stringResource(R.string.queue_reordered)
|
val queueReordered = stringResource(R.string.queue_reordered)
|
||||||
val queueRemoved = stringResource(R.string.queue_removed)
|
val queueRemoved = stringResource(R.string.queue_removed)
|
||||||
@@ -469,28 +478,14 @@ fun QueueScreen(
|
|||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
Modifier.draggableHandle(
|
Modifier.draggableHandle(
|
||||||
onDragStarted = {
|
onDragStarted = { dragId = row.id },
|
||||||
dragId = row.id
|
|
||||||
dragFrom = row.position
|
|
||||||
},
|
|
||||||
onDragStopped = {
|
onDragStopped = {
|
||||||
val id = dragId
|
dragId?.let { id ->
|
||||||
val from = dragFrom
|
rows.dropMoveFor(id)?.let { move ->
|
||||||
if (id != null && from != null) {
|
if (onMove(move.from, move.to)) showUndo(queueReordered)
|
||||||
val draggedIndex = rows.indexOfFirst { it.id == id }
|
|
||||||
val to =
|
|
||||||
if (draggedIndex < 0) {
|
|
||||||
from
|
|
||||||
} else {
|
|
||||||
dropTarget(rows, draggedIndex, from)
|
|
||||||
}
|
|
||||||
if (to != from) {
|
|
||||||
onMove(from, to)
|
|
||||||
showUndo(queueReordered)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dragId = null
|
dragId = null
|
||||||
dragFrom = null
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -562,6 +557,20 @@ internal fun dropTarget(
|
|||||||
return (before - if (before > from) 1 else 0) + 1
|
return (before - if (before > from) 1 else 0) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal data class QueueMove(
|
||||||
|
val from: Long,
|
||||||
|
val to: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun List<QueueRow>.dropMoveFor(draggedId: Long): QueueMove? {
|
||||||
|
val index = indexOfFirst { it.id == draggedId }
|
||||||
|
if (index < 0) return null
|
||||||
|
val from = this[index].position
|
||||||
|
val to = dropTarget(this, index, from)
|
||||||
|
return if (to == from) null else QueueMove(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("ModifierParameter")
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueRowItem(
|
private fun QueueRowItem(
|
||||||
row: QueueRow,
|
row: QueueRow,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -45,6 +46,7 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import com.subtracks.ui.components.ArtistArt
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
@@ -59,7 +61,7 @@ import org.koin.compose.koinInject
|
|||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
enum class SearchFilter(
|
enum class SearchFilter(
|
||||||
@StringRes val label: Int,
|
@param:StringRes val label: Int,
|
||||||
) {
|
) {
|
||||||
Songs(R.string.search_songs),
|
Songs(R.string.search_songs),
|
||||||
Albums(R.string.resources_album_name),
|
Albums(R.string.resources_album_name),
|
||||||
@@ -85,6 +87,7 @@ fun SearchRoute(
|
|||||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||||
val results by viewModel.results.collectAsStateWithLifecycle()
|
val results by viewModel.results.collectAsStateWithLifecycle()
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
var filterMask by rememberSaveable { mutableIntStateOf(allFilterMask()) }
|
var filterMask by rememberSaveable { mutableIntStateOf(allFilterMask()) }
|
||||||
val actions =
|
val actions =
|
||||||
ItemActions(
|
ItemActions(
|
||||||
@@ -104,13 +107,29 @@ fun SearchRoute(
|
|||||||
if (next != 0) filterMask = next
|
if (next != 0) filterMask = next
|
||||||
},
|
},
|
||||||
coverArt = viewModel::coverArt,
|
coverArt = viewModel::coverArt,
|
||||||
|
artistArt = { viewModel.artistArt(it) },
|
||||||
playingSongId = playback.item?.id,
|
playingSongId = playback.item?.id,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = {
|
||||||
onArtistClick = onArtistClick,
|
focusManager.clearFocus()
|
||||||
onPlaylistClick = onPlaylistClick,
|
onAlbumClick(it)
|
||||||
onSongClick = viewModel::play,
|
},
|
||||||
onLongClick = { contextMenuHost?.show(it, actions) },
|
onArtistClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onArtistClick(it)
|
||||||
|
},
|
||||||
|
onPlaylistClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
onPlaylistClick(it)
|
||||||
|
},
|
||||||
|
onSongClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
viewModel.play(it)
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
contextMenuHost?.show(it, actions)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,9 +144,11 @@ fun SearchScreen(
|
|||||||
query: String,
|
query: String,
|
||||||
onQueryChange: (String) -> Unit,
|
onQueryChange: (String) -> Unit,
|
||||||
results: SearchResults,
|
results: SearchResults,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
filters: Set<SearchFilter> = ALL_FILTERS,
|
filters: Set<SearchFilter> = ALL_FILTERS,
|
||||||
onToggleFilter: (SearchFilter) -> Unit = {},
|
onToggleFilter: (SearchFilter) -> Unit = {},
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||||
|
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||||
playingSongId: String? = null,
|
playingSongId: String? = null,
|
||||||
onBack: () -> Unit = {},
|
onBack: () -> Unit = {},
|
||||||
onAlbumClick: (Album) -> Unit = {},
|
onAlbumClick: (Album) -> Unit = {},
|
||||||
@@ -135,7 +156,6 @@ fun SearchScreen(
|
|||||||
onPlaylistClick: (Playlist) -> Unit = {},
|
onPlaylistClick: (Playlist) -> Unit = {},
|
||||||
onSongClick: (Song) -> Unit = {},
|
onSongClick: (Song) -> Unit = {},
|
||||||
onLongClick: (MenuTarget) -> Unit = {},
|
onLongClick: (MenuTarget) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
) {
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val backLabel = stringResource(R.string.navigation_back)
|
val backLabel = stringResource(R.string.navigation_back)
|
||||||
@@ -198,7 +218,7 @@ fun SearchScreen(
|
|||||||
contentPadding = PaddingValues(bottom = 24.dp),
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
) {
|
) {
|
||||||
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick)
|
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick)
|
||||||
if (showArtists) artistResults(results.artists, artistsLabel, coverArt, onArtistClick, onLongClick)
|
if (showArtists) artistResults(results.artists, artistsLabel, artistArt, onArtistClick, onLongClick)
|
||||||
if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
|
if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
|
||||||
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
|
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
|
||||||
}
|
}
|
||||||
@@ -291,19 +311,20 @@ private fun LazyListScope.albumResults(
|
|||||||
private fun LazyListScope.artistResults(
|
private fun LazyListScope.artistResults(
|
||||||
artists: List<Artist>,
|
artists: List<Artist>,
|
||||||
header: String,
|
header: String,
|
||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
artistArt: (Artist) -> ArtistArt,
|
||||||
onClick: (Artist) -> Unit,
|
onClick: (Artist) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (artists.isEmpty()) return
|
if (artists.isEmpty()) return
|
||||||
resultHeader(header)
|
resultHeader(header)
|
||||||
items(artists, key = { "artist-${it.id}" }) { artist ->
|
items(artists, key = { "artist-${it.id}" }) { artist ->
|
||||||
|
val art = artistArt(artist)
|
||||||
ArtistListRow(
|
ArtistListRow(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
coverArt = coverArt,
|
art = art,
|
||||||
info = null,
|
info = null,
|
||||||
onClick = { onClick(artist) },
|
onClick = { onClick(artist) },
|
||||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user