Compare commits
260 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d426c08cdd | ||
|
|
972c32b9d8 | ||
|
|
a279e20a49 | ||
|
|
fe60fea928 | ||
|
|
a110faabe3 | ||
|
|
df2bf43492 | ||
|
|
b46fea6890 | ||
|
|
08b6379601 | ||
|
|
3fbadc2521 | ||
|
|
9e78caeda4 | ||
|
|
e072a49288 | ||
|
|
b89e18eebf | ||
|
|
63607794d6 | ||
|
|
37842fd897 | ||
|
|
a1397a224b | ||
|
|
804d6af6c3 | ||
|
|
e315169005 | ||
|
|
ea76afee09 | ||
|
|
45dda3af9b | ||
|
|
3d70b51244 | ||
|
|
22f196c8c0 | ||
|
|
540aa9ba73 | ||
|
|
1ff0b83a19 | ||
|
|
27f5a47cc9 | ||
|
|
732b6ad09d | ||
|
|
0df7346a14 | ||
|
|
786697109d | ||
|
|
1bfadb0669 | ||
|
|
79dc1cc93b | ||
|
|
38fb2c69f1 | ||
|
|
b34f827bc0 | ||
|
|
97d1b408e1 | ||
|
|
a5065578ca | ||
|
|
aac5c6067d | ||
|
|
cfd7cf314b | ||
|
|
c4b73f6014 | ||
|
|
35d377ce31 | ||
|
|
5e330ac451 | ||
|
|
8188ef169c | ||
|
|
3496918ce6 | ||
|
|
c72f368f6a | ||
|
|
eb089847e0 | ||
|
|
8aaa6b207e | ||
|
|
72d560e4eb | ||
|
|
31219ea754 | ||
|
|
52c411ead0 | ||
|
|
0edbd15d47 | ||
|
|
342241963a | ||
|
|
f5b381eb35 | ||
|
|
be33401b6f | ||
|
|
c415db0cc5 | ||
|
|
35576c3d6f | ||
|
|
a11fbfa829 | ||
|
|
26d1b144e4 | ||
|
|
16b63bf13c | ||
|
|
52434f3aa9 | ||
|
|
7aa325f914 | ||
|
|
5a8a631449 | ||
|
|
e6bbd7b2bf | ||
|
|
3721484dff | ||
|
|
6698052ba5 | ||
|
|
f6f24acfdf | ||
|
|
ca8bcba0d7 | ||
|
|
9a36f8541f | ||
|
|
0026dc287f | ||
|
|
c65077172d | ||
|
|
6e6c261f35 | ||
|
|
130cbbd7dd | ||
|
|
63668f5a8c | ||
|
|
193b551773 | ||
|
|
76a0e12222 | ||
|
|
887d8c85ee | ||
|
|
6a90f06084 | ||
|
|
f7a21cbb52 | ||
|
|
3c6c240b9d | ||
|
|
2553c06a9f | ||
|
|
62a10d142e | ||
|
|
955dc1b015 | ||
|
|
6124ec66f3 | ||
|
|
2c6287405e | ||
|
|
0be309fb22 | ||
|
|
a79543569d | ||
|
|
e3d7120193 | ||
|
|
80a3a54476 | ||
|
|
6448cc598d | ||
|
|
c4bd30d512 | ||
|
|
b2e3596d87 | ||
|
|
ccfe74a6ea | ||
|
|
f4ffdc985e | ||
|
|
33981f9885 | ||
|
|
1bc93cce0e | ||
|
|
ec0eee9d3f | ||
|
|
140546ca4d | ||
|
|
fd075a02c5 | ||
|
|
4d1d953a3a | ||
|
|
9dd509be22 | ||
|
|
efaae35976 | ||
|
|
35784216bc | ||
|
|
8ce0a82506 | ||
|
|
51883cd82b | ||
|
|
829c5d85f6 | ||
|
|
748a19ef44 | ||
|
|
e987226954 | ||
|
|
59e2f4a7fa | ||
|
|
817b53efaa | ||
|
|
12c7ec86a9 | ||
|
|
611b5001be | ||
|
|
923cfd5bc9 | ||
|
|
36d2320e70 | ||
|
|
17713ee400 | ||
|
|
ee3465868e | ||
|
|
9e8141a8d9 | ||
|
|
043e1b39b0 | ||
|
|
938c1de906 | ||
|
|
a0dfe63660 | ||
|
|
42b7441467 | ||
|
|
28c2f87b26 | ||
|
|
9ab22bfede | ||
|
|
20900fb557 | ||
|
|
7457c5b6e3 | ||
|
|
e5a928ec0f | ||
|
|
147c8360a6 | ||
|
|
d5d504fc64 | ||
|
|
24eead2d0a | ||
|
|
2644fa52b6 | ||
|
|
38c144c073 | ||
|
|
1dca1ef68d | ||
|
|
ba94d7e5cc | ||
|
|
0028872e3f | ||
|
|
be9eec625a | ||
|
|
b335ddec01 | ||
|
|
8ad35ce83a | ||
|
|
eb5c4721d1 | ||
|
|
b0e8fa75ca | ||
|
|
27d7288ee9 | ||
|
|
287921de09 | ||
|
|
e5cb8793b0 | ||
|
|
f25e7f250a | ||
|
|
911acc3c2d | ||
|
|
4b7f60bb8c | ||
|
|
d35146dba3 | ||
|
|
6c3897a400 | ||
|
|
1002499d92 | ||
|
|
4464b5b34d | ||
|
|
77c0b86dac | ||
|
|
0abdfc6b19 | ||
|
|
52b2ca8fa7 | ||
|
|
7f66124614 | ||
|
|
9930537486 | ||
|
|
5d51132921 | ||
|
|
4b2e963a81 | ||
|
|
4c865e199d | ||
|
|
fc58869354 | ||
|
|
5e1a2b41e9 | ||
|
|
77bdd71d79 | ||
|
|
7267c13ee0 | ||
|
|
4ab1f034d8 | ||
|
|
4bd8bbfa4c | ||
|
|
3fc03114e2 | ||
|
|
576c93e6cb | ||
|
|
ac674d937a | ||
|
|
0ed329022e | ||
|
|
b8b4a77349 | ||
|
|
de14663b25 | ||
|
|
747af0d81c | ||
|
|
c95e7cc5e0 | ||
|
|
0c3b43c5dc | ||
|
|
830e9076f1 | ||
|
|
cd9ae97bc7 | ||
|
|
1b59a8e8ef | ||
|
|
391405fc76 | ||
|
|
4bdcbacf62 | ||
|
|
6cbae700bf | ||
|
|
e7555119f0 | ||
|
|
e228e74e6a | ||
|
|
062f4db2cf | ||
|
|
cb75e34b92 | ||
|
|
36005c5f51 | ||
|
|
8ae0900269 | ||
|
|
f286c7b1b9 | ||
|
|
c5d0af67a7 | ||
|
|
3fb4ccd791 | ||
|
|
577b50a85b | ||
|
|
e6a56ba1d2 | ||
|
|
2740b6da29 | ||
|
|
21c4ae77ba | ||
|
|
7a83a03a90 | ||
|
|
be0480538e | ||
|
|
b48057b4a2 | ||
|
|
fa430eaac4 | ||
|
|
82ee9b4639 | ||
|
|
9b807fde31 | ||
|
|
c7ba4235b3 | ||
|
|
d27e431f73 | ||
|
|
d23eea4f27 | ||
|
|
430e7105eb | ||
|
|
024c4e6118 | ||
|
|
7b2ee9da3a | ||
|
|
c3cce18600 | ||
|
|
442fe1ea01 | ||
|
|
cb0874dca4 | ||
|
|
079149c1d5 | ||
|
|
118f742cb6 | ||
|
|
c028c52576 | ||
|
|
96c5d0fca8 | ||
|
|
e39a5e2d5c | ||
|
|
a06ab77b42 | ||
|
|
04a6176bfd | ||
|
|
1f4464e089 | ||
|
|
9d01d2057a | ||
|
|
ad440c490a | ||
|
|
acdcfff9ac | ||
|
|
8c7a25cbd0 | ||
|
|
bdca5e16ed | ||
|
|
f091b3d248 | ||
|
|
18cd84f820 | ||
|
|
281ebf8263 | ||
|
|
2854ac6354 | ||
|
|
16d25a1f1d | ||
|
|
5d3ca8acfa | ||
|
|
0689272046 | ||
|
|
17372fc4d0 | ||
|
|
44679855cd | ||
|
|
78e7032903 | ||
|
|
8d8087f2d6 | ||
|
|
5b6a4fab62 | ||
|
|
fdc41b299c | ||
|
|
82c22ed247 | ||
|
|
48ce3a2a4f | ||
|
|
b93acc6563 | ||
|
|
9c088a7e88 | ||
|
|
de2f1067a7 | ||
|
|
1c21546461 | ||
|
|
a4121e8d49 | ||
|
|
ceaffa254b | ||
|
|
4cc4cc7363 | ||
|
|
c5ef274916 | ||
|
|
2c53f36a18 | ||
|
|
6c637dcbcb | ||
|
|
89fa38f5a0 | ||
|
|
87f8bdc618 | ||
|
|
903fde4bdc | ||
|
|
3824dd882c | ||
|
|
602bab6414 | ||
|
|
d891e429b6 | ||
|
|
ebefd77027 | ||
|
|
57f34affd9 | ||
|
|
50b5ab38bc | ||
|
|
9f61d70fca | ||
|
|
a97a2d5b50 | ||
|
|
8d73a2cd36 | ||
|
|
ca5a0698bb | ||
|
|
04f34e03d1 | ||
|
|
233bc9987e | ||
|
|
f0e418687e | ||
|
|
e87b658447 | ||
|
|
19c985c9e4 | ||
|
|
4ab122a9d7 | ||
|
|
2335bf2095 | ||
|
|
ffcfd81c28 |
1
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -26,6 +26,7 @@ Outline the steps required to reproduce the bug, including any specific actions,
|
||||
- Android device: [Device Model]
|
||||
- Android OS version: [Android Version]
|
||||
- App version: [App Version]
|
||||
- App variant: [goole play services, degoogled]
|
||||
- Other relevant details: [e.g., specific network conditions, external dependencies]
|
||||
|
||||
## Logs or Screenshots
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/crash-report.md
vendored
@@ -23,10 +23,11 @@ Please provide the steps to reproduce the crash:
|
||||
- Android device: [Device Model]
|
||||
- Android OS version: [Android Version]
|
||||
- App version: [App Version]
|
||||
- App variant: [goole play services, degoogled]
|
||||
- Other relevant details: [e.g., specific network conditions, external dependencies]
|
||||
|
||||
## Crash Logs/Stack trace
|
||||
<!-- If available, please provide the crash log or stack trace related to the crash. Include it inside a code block (surround with triple backticks ```). Please use the unsigned apk (app-tempo-debug.apk), as the logs would be illegible and therefore useless for this purpose. -->
|
||||
<!-- If available, please provide the crash log or stack trace related to the crash. Include it inside a code block (surround with triple backticks ```). Please use the unsigned apk (app-tempus-debug.apk), as the logs would be illegible and therefore useless for this purpose. -->
|
||||
|
||||
## Screenshots
|
||||
<!-- If applicable, add screenshots to help explain the problem. -->
|
||||
|
||||
95
.github/workflows/github_release.yml
vendored
@@ -35,15 +35,21 @@ jobs:
|
||||
echo "BUILD_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
|
||||
echo Last build tool version is: $BUILD_TOOL_VERSION
|
||||
|
||||
- name: Build APK
|
||||
- name: Build All Release APKs
|
||||
id: build
|
||||
run: bash ./gradlew assembleTempoRelease
|
||||
run: |
|
||||
# Only build release variants (removed debug builds)
|
||||
bash ./gradlew assembleTempusRelease
|
||||
bash ./gradlew assembleDegoogledRelease
|
||||
|
||||
- name: Sign APK
|
||||
id: sign_apk
|
||||
- name: Create Artifact Staging Directory
|
||||
run: mkdir -p release-artifacts
|
||||
|
||||
- name: Sign Tempus Release APKs
|
||||
id: sign_tempus_release
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
releaseDirectory: app/build/outputs/apk/tempo/release
|
||||
releaseDirectory: app/build/outputs/apk/tempus/release
|
||||
signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
alias: ${{ secrets.KEY_ALIAS_GITHUB }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
@@ -51,28 +57,73 @@ jobs:
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
|
||||
|
||||
- name: Make artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Prepare Signed Tempus APKs for Release
|
||||
run: |
|
||||
TEMPUS_PATH=app/build/outputs/apk/tempus/release
|
||||
|
||||
echo "--- Tempus Files BEFORE Move ---"
|
||||
ls -la $TEMPUS_PATH
|
||||
echo "--------------------------------"
|
||||
|
||||
# FIX: Use find/xargs for robust file matching and moving.
|
||||
|
||||
# Renaming 64-bit APK and moving to safe staging directory
|
||||
find $TEMPUS_PATH -name '*arm64-v8a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/
|
||||
mv ./release-artifacts/*arm64-v8a*signed.apk ./release-artifacts/app-tempus-arm64-v8a-release.apk
|
||||
|
||||
# Renaming 32-bit APK and moving to safe staging directory
|
||||
find $TEMPUS_PATH -name '*armeabi-v7a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/
|
||||
mv ./release-artifacts/*armeabi-v7a*signed.apk ./release-artifacts/app-tempus-armeabi-v7a-release.apk
|
||||
|
||||
echo "Prepared Tempus APKs."
|
||||
|
||||
- name: Sign Degoogled Release APKs
|
||||
id: sign_degoogled_release
|
||||
uses: r0adkll/sign-android-release@v1
|
||||
with:
|
||||
name: app-release-signed
|
||||
path: ${{steps.sign_apk.outputs.signedReleaseFile}}
|
||||
releaseDirectory: app/build/outputs/apk/degoogled/release
|
||||
signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }}
|
||||
alias: ${{ secrets.KEY_ALIAS_GITHUB }}
|
||||
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
keyPassword: ${{ secrets.KEY_PASSWORD_GITHUB }}
|
||||
env:
|
||||
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
|
||||
|
||||
- name: Prepare Signed Degoogled APKs for Release
|
||||
run: |
|
||||
DEGOOGLED_PATH=app/build/outputs/apk/degoogled/release
|
||||
|
||||
echo "--- Degoogled Files BEFORE Move ---"
|
||||
ls -la $DEGOOGLED_PATH
|
||||
echo "--------------------------------"
|
||||
|
||||
# FIX: Use find/xargs for robust file matching and moving.
|
||||
|
||||
# Renaming 64-bit APK and moving to safe staging directory
|
||||
find $DEGOOGLED_PATH -name '*arm64-v8a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/
|
||||
mv ./release-artifacts/*arm64-v8a*signed.apk ./release-artifacts/app-degoogled-arm64-v8a-release.apk
|
||||
|
||||
# Renaming 32-bit APK and moving to safe staging directory
|
||||
find $DEGOOGLED_PATH -name '*armeabi-v7a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/
|
||||
mv ./release-artifacts/*armeabi-v7a*signed.apk ./release-artifacts/app-degoogled-armeabi-v7a-release.apk
|
||||
|
||||
echo "Prepared Degoogled APKs."
|
||||
ls -la ./release-artifacts/
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: Release v${{ github.ref }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: ${{ github.ref_name }}
|
||||
body: '> Changelog coming soon'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: ./release-artifacts/*.apk
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
- name: Upload Release APKs as artifacts (For easy pipeline access)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ${{steps.sign_apk.outputs.signedReleaseFile}}
|
||||
asset_name: app-tempo-release.apk
|
||||
asset_content_type: application/zip
|
||||
name: release-apks
|
||||
path: ./release-artifacts/*.apk
|
||||
retention-days: 30
|
||||
|
||||
3
.gitignore
vendored
@@ -17,4 +17,5 @@
|
||||
.vscode/settings.json
|
||||
# release / debug files
|
||||
tempus-release-key.jks
|
||||
app/tempo/
|
||||
app/tempus/
|
||||
app/degoogled/
|
||||
|
||||
181
CHANGELOG.md
@@ -1,6 +1,183 @@
|
||||
# Changelog
|
||||
|
||||
***This log is for this fork to detail updates since 3.9.0 from the main repo.***
|
||||
## Pending release...
|
||||
|
||||
* fix: updates starred syncing downloads to user defined directory by @eddyizm in https://github.com/eddyizm/tempus/pull/298
|
||||
* fix: handle empty albums and null mappings by @eddyizm in https://github.com/eddyizm/tempus/pull/301
|
||||
* feat: integrate sort recent searches chronologically by @J4mm3ris in https://github.com/eddyizm/tempus/pull/300
|
||||
* feat: add heart to artist/album pages, fixed artist cover art failing by @eddyizm in https://github.com/eddyizm/tempus/pull/303
|
||||
|
||||
## [4.4.0](https://github.com/eddyizm/tempo/releases/tag/v4.4.0) (2025-11-29)
|
||||
## What's Changed
|
||||
* chore: bringing in media service refactor previously reverted after more testing by @eddyizm in https://github.com/eddyizm/tempus/pull/286
|
||||
* fix: refactor start queue to put the db writing in the background to address instant mix bug by @eddyizm in https://github.com/eddyizm/tempus/pull/287
|
||||
* Feat: playerqueue fab allows playqueue actions -> saving to playlist, download all, load queue, shuffle, clean queue by @eddyizm in https://github.com/eddyizm/tempus/pull/288
|
||||
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/291
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.3.0...v4.4.0
|
||||
|
||||
## [4.3.0](https://github.com/eddyizm/tempo/releases/tag/v4.3.0) (2025-11-23)
|
||||
## What's Changed
|
||||
* chore: Add Obtainium badge to README by @mikaeldui in https://github.com/eddyizm/tempus/pull/280
|
||||
* fix: Revert "refactor MediaService" by @eddyizm in https://github.com/eddyizm/tempus/pull/282
|
||||
* feat: add play functionality to library folder/index items by @antebudimir in https://github.com/eddyizm/tempus/pull/276
|
||||
* fix: start queue blocking UI by @eddyizm in https://github.com/eddyizm/tempus/pull/283
|
||||
|
||||
## New Contributors
|
||||
* @mikaeldui made their first contribution in https://github.com/eddyizm/tempus/pull/280
|
||||
* @antebudimir made their first contribution in https://github.com/eddyizm/tempus/pull/276
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.2.6...v4.3.0
|
||||
|
||||
## [4.2.6](https://github.com/eddyizm/tempo/releases/tag/v4.2.6) (2025-11-22)
|
||||
## What's Changed
|
||||
* fix: Fix player queue soft-lock by @shrapnelnet in https://github.com/eddyizm/tempus/pull/266
|
||||
* chore: Add Catalan i18n by @marcriera in https://github.com/eddyizm/tempus/pull/268
|
||||
* chore: Refactor MediaService by @pca006132 in https://github.com/eddyizm/tempus/pull/267
|
||||
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/272
|
||||
* chore(i18n): Update Italian translation by @66Bunz in https://github.com/eddyizm/tempus/pull/278
|
||||
|
||||
## New Contributors
|
||||
* @marcriera made their first contribution in https://github.com/eddyizm/tempus/pull/268
|
||||
* @66Bunz made their first contribution in https://github.com/eddyizm/tempus/pull/278
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.2.4...v4.2.6
|
||||
|
||||
## [4.2.4](https://github.com/eddyizm/tempo/releases/tag/v4.2.4) (2025-11-15)
|
||||
## What's Changed
|
||||
* chore: Update russian strings.xml by @Sevinfolds in https://github.com/eddyizm/tempus/pull/249
|
||||
* fix: disallow duplicate songs in queue by @eddyizm in https://github.com/eddyizm/tempus/pull/252
|
||||
* fix:github release check by @eddyizm in https://github.com/eddyizm/tempus/pull/253
|
||||
* fix: Fixed crash when viewing share by @drakeerv in https://github.com/eddyizm/tempus/pull/255
|
||||
* chore: Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/257
|
||||
* fix: add podcast/radio channel visible when empty podcasts/radio by @eddyizm in https://github.com/eddyizm/tempus/pull/260
|
||||
|
||||
## New Contributors
|
||||
* @Sevinfolds made their first contribution in https://github.com/eddyizm/tempus/pull/249
|
||||
* @drakeerv made their first contribution in https://github.com/eddyizm/tempus/pull/255
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.2.0...v4.2.4
|
||||
## [4.2.0](https://github.com/eddyizm/tempo/releases/tag/v4.2.0) (2025-11-09)
|
||||
## What's Changed
|
||||
* fix: Equalizer fix in main build variant by @jaime-grj in https://github.com/eddyizm/tempus/pull/239
|
||||
* fix: Images not filling holder by @eddyizm in https://github.com/eddyizm/tempus/pull/244
|
||||
* feat: Make artist and album clickable by @eddyizm in https://github.com/eddyizm/tempus/pull/243
|
||||
* feat: implement scroll to currently playing feature by @shrapnelnet in https://github.com/eddyizm/tempus/pull/247
|
||||
* fix: shuffling genres only queuing 25 songs by @shrapnelnet in https://github.com/eddyizm/tempus/pull/246
|
||||
|
||||
## New Contributors
|
||||
* @shrapnelnet made their first contribution in https://github.com/eddyizm/tempus/pull/247
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.1.3...v4.2.0
|
||||
|
||||
## [4.1.3](https://github.com/eddyizm/tempo/releases/tag/v4.1.3) (2025-11-06)
|
||||
## What's Changed
|
||||
* [fix: equalizer missing referenced value](https://github.com/eddyizm/tempus/commit/923cfd5bc97ed7db28c90348e3619d0a784fc434)
|
||||
* Fix: Album track list bug by @eddyizm in https://github.com/eddyizm/tempus/pull/237
|
||||
* fix: Add listener to enable equalizer when audioSessionId changes by @jaime-grj in https://github.com/eddyizm/tempus/pull/235
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.1.0...v4.1.3
|
||||
|
||||
## [4.1.0](https://github.com/eddyizm/tempo/releases/tag/v4.1.0) (2025-11-05)
|
||||
## What's Changed
|
||||
* chore(i18n): Update Spanish (es-ES) translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/205
|
||||
* shuffle for artists without using `getTopSongs` by @pca006132 in https://github.com/eddyizm/tempus/pull/207
|
||||
* Update USAGE.md with instant mix details by @zc-devs in https://github.com/eddyizm/tempus/pull/220
|
||||
* feat: sort artists by album count by @pca006132 in https://github.com/eddyizm/tempus/pull/206
|
||||
* Fix downloaded tab performance by @pca006132 in https://github.com/eddyizm/tempus/pull/210
|
||||
* fix: remove NestedScrollViews for fragment_album_page by @pca006132 in https://github.com/eddyizm/tempus/pull/216
|
||||
* fix: playlist page should not snap by @pca006132 in https://github.com/eddyizm/tempus/pull/218
|
||||
* fix: do not override getItemViewType and getItemId by @pca006132 in https://github.com/eddyizm/tempus/pull/221
|
||||
* chore: update media3 dependencies by @pca006132 in https://github.com/eddyizm/tempus/pull/217
|
||||
* fix: update MediaItems after network change by @pca006132 in https://github.com/eddyizm/tempus/pull/222
|
||||
* fix: skip mapping downloaded item by @pca006132 in https://github.com/eddyizm/tempus/pull/228
|
||||
|
||||
## New Contributors
|
||||
* @pca006132 made their first contribution in https://github.com/eddyizm/tempus/pull/207
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.0.7...v4.1.0
|
||||
|
||||
## [4.0.7](https://github.com/eddyizm/tempo/releases/tag/v4.0.7) (2025-10-28)
|
||||
## What's Changed
|
||||
* chore: updated tempo references to tempus including github check by @eddyizm in https://github.com/eddyizm/tempus/pull/197
|
||||
* fix: Crash on share no expiration date or field returned from api by @eddyizm in https://github.com/eddyizm/tempus/pull/199
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.0.6...v4.0.7
|
||||
|
||||
## [4.0.6](https://github.com/eddyizm/tempo/releases/tag/v4.0.6) (2025-10-26)
|
||||
## Attention
|
||||
This release will not update previous installs as it is considered a new app, no longer `Tempo`, new icon, new app id, and new app name. Hoping it will not be a huge inconvenience but was necessary in order to publish to app stores like IzzyDroid and FDroid.
|
||||
|
||||
**Android Auto**
|
||||
Support should be the same as before, however, I was not able to test any of the icons/visuals, so please let me know if there are any remnants of the tempo logo/icon as I believe I removed them all and replaced them successfully.
|
||||
|
||||
## What's Changed
|
||||
* Check also underlying transport by @zc-devs in https://github.com/eddyizm/tempus/pull/90
|
||||
* fix: updated workflow for 32/64 bit apks by @eddyizm in https://github.com/eddyizm/tempus/pull/176
|
||||
* Unhide genre from album details view by @sebaFlame in https://github.com/eddyizm/tempus/pull/161
|
||||
* fix: persist album sorting on resume by @eddyizm in https://github.com/eddyizm/tempus/pull/181
|
||||
* chore: update readme and usage references to tempus. added new banner… by @eddyizm in https://github.com/eddyizm/tempus/pull/182
|
||||
* Tempus rebrand by @eddyizm in https://github.com/eddyizm/tempus/pull/183
|
||||
* Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/188
|
||||
|
||||
## New Contributors
|
||||
* @zc-devs made their first contribution in https://github.com/eddyizm/tempus/pull/90
|
||||
* @sebaFlame made their first contribution in https://github.com/eddyizm/tempus/pull/161
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v3.17.14...v4.0.1
|
||||
|
||||
## [3.17.14](https://github.com/eddyizm/tempo/releases/tag/v3.17.14) (2025-10-16)
|
||||
## What's Changed
|
||||
* fix: General build warning and playback issues by @le-firehawk in https://github.com/eddyizm/tempo/pull/167
|
||||
* fix: persist album sort preference by @eddyizm in https://github.com/eddyizm/tempo/pull/168
|
||||
* Fix album parse empty date field by @eddyizm in https://github.com/eddyizm/tempo/pull/171
|
||||
* fix: Include shuffle/repeat controls in f-droid build's media notific… by @le-firehawk in https://github.com/eddyizm/tempo/pull/174
|
||||
* fix: limits image size to prevent widget crash #172 by @eddyizm in https://github.com/eddyizm/tempo/pull/175
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.17.0...v3.17.14
|
||||
|
||||
## [3.17.0](https://github.com/eddyizm/tempo/releases/tag/v3.17.0) (2025-10-10)
|
||||
## What's Changed
|
||||
* chore: adding screenshot and docs for 4 icons/buttons in player control by @eddyizm in https://github.com/eddyizm/tempo/pull/162
|
||||
* Update Polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/160
|
||||
* feat: Make all objects in Tempo references for quick access by @le-firehawk in https://github.com/eddyizm/tempo/pull/158
|
||||
* fix: Glide module incorrectly encoding IPv6 addresses by @le-firehawk in https://github.com/eddyizm/tempo/pull/159
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.16.6...v3.17.0
|
||||
|
||||
## [3.16.6](https://github.com/eddyizm/tempo/releases/tag/v3.16.6) (2025-10-08)
|
||||
## What's Changed
|
||||
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempo/pull/151
|
||||
* fix: Re-add new equalizer settings that got lost by @jaime-grj in https://github.com/eddyizm/tempo/pull/153
|
||||
* chore: removed play variant by @eddyizm in https://github.com/eddyizm/tempo/pull/155
|
||||
* fix: updating release workflow to account for the 32/64 bit builds an… by @eddyizm in https://github.com/eddyizm/tempo/pull/156
|
||||
* feat: Show sampling rate and bit depth in downloads by @jaime-grj in https://github.com/eddyizm/tempo/pull/154
|
||||
* fix: Replace hardcoded strings in SettingsFragment by @jaime-grj in https://github.com/eddyizm/tempo/pull/152
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.16.0...v3.16.6
|
||||
|
||||
## [3.16.0](https://github.com/eddyizm/tempo/releases/tag/v3.16.0) (2025-10-07)
|
||||
## What's Changed
|
||||
* chore: add sha256 fingerprint for validation by @eddyizm in https://github.com/eddyizm/tempo/commit/3c58e6fbb2157a804853259dfadbbffe3b6793b5
|
||||
* fix: Prevent crash when getting artist radio and song list is null by @jaime-grj in https://github.com/eddyizm/tempo/pull/117
|
||||
* chore: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/125
|
||||
* fix: Update search query validation to require at least 2 characters instead of 3 by @jaime-grj in https://github.com/eddyizm/tempo/pull/124
|
||||
* feat: download starred artists. by @eddyizm in https://github.com/eddyizm/tempo/pull/137
|
||||
* feat: Enable downloading of song lyrics for offline viewing by @le-firehawk in https://github.com/eddyizm/tempo/pull/99
|
||||
* fix: Lag during startup when local url is not available by @SinTan1729 in https://github.com/eddyizm/tempo/pull/110
|
||||
* chore: add link to discussion page in settings by @eddyizm in https://github.com/eddyizm/tempo/pull/143
|
||||
* feat: Notification heart rating by @eddyizm in https://github.com/eddyizm/tempo/pull/140
|
||||
* chore: Unify and update polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/146
|
||||
* chore: added sha256 signing key for verification by @eddyizm in https://github.com/eddyizm/tempo/pull/147
|
||||
* feat: Support user-defined download directory for media by @le-firehawk in https://github.com/eddyizm/tempo/pull/21
|
||||
* feat: Added support for skipping duplicates by @SinTan1729 in https://github.com/eddyizm/tempo/pull/135
|
||||
* feat: Add home screen music playback widget and some updates in Turkish localization by @mucahit-kaya in https://github.com/eddyizm/tempo/pull/98
|
||||
|
||||
## New Contributors
|
||||
* @SinTan1729 made their first contribution in https://github.com/eddyizm/tempo/pull/110
|
||||
|
||||
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.15.0...v3.16.0
|
||||
|
||||
## [3.15.0](https://github.com/eddyizm/tempo/releases/tag/v3.15.0) (2025-09-23)
|
||||
## What's Changed
|
||||
@@ -117,3 +294,5 @@
|
||||
[\#400](https://github.com/CappielloAntonio/tempo/pull/400)
|
||||
- [Chore] Spanish translation [\#374](https://github.com/CappielloAntonio/tempo/pull/374)
|
||||
- [Chore] Polish translation [\#378](https://github.com/CappielloAntonio/tempo/pull/378)
|
||||
|
||||
***This log is for this fork to detail updates since 3.9.0 from the main repo.***
|
||||
112
README.md
@@ -1,60 +1,73 @@
|
||||
<p align="center">
|
||||
<img alt="Tempo" title="Tempo" src="mockup/svg/horizontal_logo.svg" width="250">
|
||||
<img alt="Tempus" title="Tempus" src="mockup/svg/tempus_horizontal_logo.png" width="250">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<b>Access your music library on all your android devices</b>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<!-- Reproducible build -->
|
||||
<a href="https://shields.rbtlog.dev/com.eddyizm.degoogled.tempus"><img src="https://shields.rbtlog.dev/simple/com.eddyizm.degoogled.tempus" alt="RB Status"></a>
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/eddyizm/tempo/releases"><img src="https://i.ibb.co/q0mdc4Z/get-it-on-github.png" width="200"></a>
|
||||
<a href="https://github.com/eddyizm/tempus/releases"><img src="https://i.ibb.co/q0mdc4Z/get-it-on-github.png" width="200"></a>
|
||||
<a href="https://apt.izzysoft.de/fdroid/index/apk/com.eddyizm.degoogled.tempus"><img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" width="200"></a>
|
||||
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.eddyizm.tempus%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Feddyizm%2Ftempus%22%2C%22author%22%3A%22eddyizm%22%2C%22name%22%3A%22Tempus%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22tempus%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22includeZips%5C%22%3Afalse%2C%5C%22zippedApkFilterRegEx%5C%22%3A%5C%22%5C%22%7D%22%2C%22overrideSource%22%3A%22GitHub%22%7D"><img width="200" src="https://github.com/user-attachments/assets/119e7ff4-2636-43cb-ab7f-1b6a58ac3570" /></a>
|
||||
</p>
|
||||
<!-- <p align="center">
|
||||
<!--
|
||||
<a href="https://f-droid.org/packages/com.cappielloantonio.notquitemy.tempo"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="200"></a>
|
||||
<a href="https://apt.izzysoft.de/fdroid/index/apk/com.cappielloantonio.tempo"><img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" width="200"></a>
|
||||
</p> -->
|
||||
-->
|
||||
|
||||
|
||||
**Tempo** is an open-source and lightweight music client for Subsonic, designed and built natively for Android. It provides a seamless and intuitive music streaming experience, allowing you to access and play your Subsonic music library directly from your Android device.
|
||||
**Tempus** is an open-source and lightweight music client for Subsonic, designed and built natively for Android. It provides a seamless and intuitive music streaming experience, allowing you to access and play your Subsonic music library directly from your Android device.
|
||||
|
||||
Tempo does not rely on magic algorithms to decide what you should listen to. Instead, the interface is built around your listening history, randomness, and optionally integrates with services like Last.fm to personalize your music experience.
|
||||
Tempus does not rely on magic algorithms to decide what you should listen to. Instead, the interface is built around your listening history, randomness, and optionally integrates with services like Last.fm to personalize your music experience.
|
||||
|
||||
**If you find Tempo useful, please consider starring the project on GitHub. It would mean a lot to me and help promote the app to a wider audience.**
|
||||
The project is a fork of [Tempo](#credits).
|
||||
|
||||
**If you find Tempus useful, please consider starring the project on GitHub. It would mean a lot to me and help promote the app to a wider audience.**
|
||||
|
||||
**Use the Github version of the app for full Android Auto and Chromecast support.**
|
||||
|
||||
## Fork
|
||||
|
||||
sha256 signing key fingerprint
|
||||
`SHA256: B7:85:01:B9:34:D0:4E:0A:CA:8D:94:AF:D6:72:6A:4D:1D:CE:65:79:7F:1D:41:71:0F:64:3C:29:00:EB:1D:1D`
|
||||
`B7:85:01:B9:34:D0:4E:0A:CA:8D:94:AF:D6:72:6A:4D:1D:CE:65:79:7F:1D:41:71:0F:64:3C:29:00:EB:1D:1D`
|
||||
|
||||
This fork is my attempt to keep development moving forward and merge in PR's that have been sitting for a while in the main repo. Thankful to @CappielloAntonio for the amazing app and hopefully we can continue to build on top of it. I will only be releasing on github and if I am not able to merge back to the main repo, I plan to rename the app to be able to publish it to fdroid and possibly google play? We will see.
|
||||
### Releases
|
||||
|
||||
Moved details to [CHANGELOG.md](https://github.com/eddyizm/tempo/blob/main/CHANGELOG.md)
|
||||
Please note the two variants in the release assets include release/debug and 32/64 bit flavors.
|
||||
|
||||
Fork [**sponsorship here**](https://ko-fi.com/eddyizm).
|
||||
`app-tempus` <- The github release with all the android auto/chromecast features
|
||||
|
||||
`app-degoogled*` <- The izzyOnDroid release that goes without any of the google stuff. It is now available on izzyOnDroid (64bit) I am releasing the both 32/64bit apk's here on github for those who need a 32bit version.
|
||||
|
||||
[CHANGELOG.md](CHANGELOG.md)
|
||||
|
||||
## Usage
|
||||
|
||||
[Documentation](USAGE.md) (work in progress)
|
||||
|
||||
## Features
|
||||
- **Subsonic Integration**: Tempo seamlessly integrates with your Subsonic server, providing you with easy access to your entire music collection on the go.
|
||||
- **Subsonic Integration**: Tempus seamlessly integrates with your Subsonic server, providing you with easy access to your entire music collection on the go.
|
||||
- **Sleek and Intuitive UI**: Enjoy a clean and user-friendly interface designed to enhance your music listening experience, tailored to your preferences and listening history.
|
||||
- **Browse and Search**: Easily navigate through your music library using various browsing and searching options, including artists, albums, genres, playlists, decades and more.
|
||||
- **Streaming and Offline Mode**: Stream music directly from your Subsonic server. Offline mode is currently under active development and may have limitations when using multiple servers.
|
||||
- **Playlist Management**: Create, edit, and manage playlists to curate your perfect music collection.
|
||||
- **Gapless Playback**: Experience uninterrupted playback with gapless listening mode.
|
||||
- **Chromecast Support**: Stream your music to Chromecast devices. The support is currently in a rudimentary state.
|
||||
- **Scrobbling Integration**: Optionally integrate Tempo with Last.fm to scrobble your played tracks, gather music insights, and further personalize your music recommendations, if supported by your Subsonic server.
|
||||
- **Podcasts and Radio**: If your Subsonic server supports it, listen to podcasts and radio shows directly within Tempo, expanding your audio entertainment options.
|
||||
- **Scrobbling Integration**: Optionally integrate Tempus with Last.fm or Listenbrainz.org to scrobble your played tracks, gather music insights, and further personalize your music recommendations, if supported by your Subsonic server.
|
||||
- **Podcasts and Radio**: If your Subsonic server supports it, listen to podcasts and radio shows directly within Tempus, expanding your audio entertainment options.
|
||||
- **Transcoding Support**: Activate transcoding of tracks on your Subsonic server, allowing you to set a transcoding profile for optimized streaming directly from the app. This feature requires support from your Subsonic server.
|
||||
- **Android Auto Support**: Enjoy your favorite music on the go with full Android Auto integration, allowing you to seamlessly control and listen to your tracks directly from your mobile device while driving.
|
||||
|
||||
## Sponsors
|
||||
Thanks to the original repo/creator [CappielloAntonio](https://github.com/CappielloAntonio) (3.9.0)
|
||||
|
||||
Tempo is an open-source project developed and maintained solely by me. I would like to express my heartfelt thanks to all the users who have shown their love and support for Tempo. Your contributions and encouragement mean a lot to me, and they help drive the development and improvement of the app.
|
||||
|
||||
- **Multiple Libraries**: Tempus handles multi-library setups gracefully. They are displayed as Library folders.
|
||||
- **Equalizer**: Option to use in app equalizer.
|
||||
- **Widget**: New widget to keeping the basic controls on your screen at all times.
|
||||
- **Available in 11 languages**: Currently in Chinese, French, German, Italian, Korean, Polish, Portuguese, Russion, Spanish and Turkish
|
||||
|
||||
## Screenshot
|
||||
|
||||
@@ -63,14 +76,13 @@ Tempo is an open-source project developed and maintained solely by me. I would l
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="mockup/light/1_screenshot.png" width=200>
|
||||
<img src="mockup/light/2_screenshot.png" width=200>
|
||||
<img src="mockup/light/3_screenshot.png" width=200>
|
||||
<img src="mockup/light/4_screenshot.png" width=200>
|
||||
<img src="mockup/light/5_screenshot.png" width=200>
|
||||
<img src="mockup/light/6_screenshot.png" width=200>
|
||||
<img src="mockup/light/7_screenshot.png" width=200>
|
||||
<img src="mockup/light/8_screenshot.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1_light.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2_light.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3_light.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4_light.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5_light.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6_light.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/8_light.png" width=200>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
@@ -80,16 +92,36 @@ Tempo is an open-source project developed and maintained solely by me. I would l
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="mockup/dark/1_screenshot.png" width=200>
|
||||
<img src="mockup/dark/2_screenshot.png" width=200>
|
||||
<img src="mockup/dark/3_screenshot.png" width=200>
|
||||
<img src="mockup/dark/4_screenshot.png" width=200>
|
||||
<img src="mockup/dark/5_screenshot.png" width=200>
|
||||
<img src="mockup/dark/6_screenshot.png" width=200>
|
||||
<img src="mockup/dark/7_screenshot.png" width=200>
|
||||
<img src="mockup/dark/8_screenshot.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1_dark.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2_dark.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3_dark.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4_dark.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5_dark.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6_dark.png" width=200>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/8_dark.png" width=200>
|
||||
|
||||
</p>
|
||||
|
||||
## Contributing
|
||||
|
||||
Please fork and open PR's against the development branch. Make sure your PR builds successfully.
|
||||
|
||||
If there is an UI change, please include a before/after screenshot and a short video/gif if that helps elaborating the fix/feature in the PR.
|
||||
|
||||
Currently there are no tests but I would love to start on some unit tests.
|
||||
|
||||
Not a hard requirement but any new feature/change should ideally include an update to the nacent documention.
|
||||
|
||||
## Support
|
||||
|
||||
[**Buy me a coffee**](https://ko-fi.com/eddyizm)
|
||||
bitcoin: `3QVHSSCJvn6yXEcJ3A3cxYLMmbvFsrnUs5`
|
||||
|
||||
## License
|
||||
|
||||
Tempo is released under the [GNU General Public License v3.0](LICENSE). Feel free to modify, distribute, and use the app in accordance with the terms of the license. Contributions to the project are also welcome.
|
||||
Tempus is released under the [GNU General Public License v3.0](LICENSE). Feel free to modify, distribute, and use the app in accordance with the terms of the license. Contributions to the project are also welcome.
|
||||
|
||||
## Credits
|
||||
Thanks to the original repo/creator [CappielloAntonio](https://github.com/CappielloAntonio) (forked from v3.9.0)
|
||||
|
||||
[Opensvg.org](https://opensvg.org) for the new turntable logo.
|
||||
|
||||
66
USAGE.md
@@ -1,4 +1,4 @@
|
||||
# Tempo Usage Guide
|
||||
# Tempus Usage Guide
|
||||
[<- back home](README.md)
|
||||
|
||||
## Table of Contents
|
||||
@@ -6,6 +6,7 @@
|
||||
- [Getting Started](#getting-started)
|
||||
- [Server Configuration](#server-configuration)
|
||||
- [Main Features](#main-features)
|
||||
|
||||
- [Navigation](#navigation)
|
||||
- [Playback Controls](#playback-controls)
|
||||
- [Favorites](#favorites)
|
||||
@@ -27,6 +28,8 @@ This app works with any service that implements the Subsonic API, including:
|
||||
- [LMS - Lightweight Music Server](https://github.com/epoupon/lms) - *personal fave and my backend*
|
||||
- [Navidrome](https://www.navidrome.org/)
|
||||
- [Gonic](https://github.com/sentriz/gonic)
|
||||
- [Ampache](https://github.com/ampache/ampache)
|
||||
- [NextCloud Music](https://apps.nextcloud.com/apps/music)
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +37,7 @@ This app works with any service that implements the Subsonic API, including:
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
1. Download the APK from the [Releases](https://github.com/eddyizm/tempo/releases) section
|
||||
1. Download the APK from the [Releases](https://github.com/eddyizm/tempus/releases) section
|
||||
2. Enable "Install from unknown sources" in your Android settings
|
||||
3. Install the application
|
||||
|
||||
@@ -57,10 +60,59 @@ This app works with any service that implements the Subsonic API, including:
|
||||
## Main Features
|
||||
|
||||
### Library View
|
||||
**TODO**
|
||||
|
||||
**Multi-library**
|
||||
|
||||
Tempus handles multi-library setups gracefully. They are displayed as Library folders.
|
||||
|
||||
However, if you want to limit or change libraries you could use a workaround, if your server supports it.
|
||||
|
||||
You can create multiple users , one for each library, and save each of them in Tempus app.
|
||||
|
||||
### Folder or index playback
|
||||
|
||||
If your Subsonic-compatible server exposes the folder tree **or** provides an artist index (for example Gonic, Navidrome, or any backend with folder browsing enabled), Tempus lets you play an entire folder from anywhere in the library hierarchy:
|
||||
|
||||
<p align="left">
|
||||
<img src="mockup/usage/music_folders_root.png" width=317 style="margin-right:16px;">
|
||||
<img src="mockup/usage/music_folders_playback.png" width=317>
|
||||
</p>
|
||||
|
||||
- The **Library ▸ Music folders** screen shows each top-level folder with a play icon only after you drill into it. The root entry remains a simple navigator.
|
||||
- When viewing **inner folders** **or artist index entries**, tap the new play button to immediately enqueue every audio track inside that folder/index and all nested subfolders.
|
||||
- Video files are excluded automatically, so only playable audio ends up in the queue.
|
||||
|
||||
No extra config is needed—Tempus adjusts based on the connected backend.
|
||||
|
||||
### Now Playing Screen
|
||||
**TODO**
|
||||
|
||||
On the main player control screen, tapping on the artwork will reveal a small collection of 4 buttons/icons.
|
||||
<p align="left">
|
||||
<img src="mockup/usage/player_icons.png" width=159>
|
||||
</p>
|
||||
|
||||
*marked the icons with numbers for clarity*
|
||||
|
||||
1. Downloads the track (there is a notification if the android screen but not a pop toast currently )
|
||||
2. Adds track to playlist - pops up playlist dialog.
|
||||
3. Adds tracks to the queue via instant mix function
|
||||
* TBD: what is the _instant mix function_?
|
||||
* Uses [getSimilarSongs](https://opensubsonic.netlify.app/docs/endpoints/getsimilarsongs/) of OpenSubsonic API.
|
||||
Which tracks to be mixed depends on the server implementation. For example, Navidrome gets 15 similar artists from LastFM, then 20 top songs from each.
|
||||
4. Saves play queue (if the feature is enabled in the settings)
|
||||
* if the setting is not enabled, it toggles a view of the lyrics if available (slides to the right)
|
||||
|
||||
### Podcasts
|
||||
If your server supports it - add a podcast rss feed
|
||||
<p align="left">
|
||||
<img src="mockup/usage/add_podcast_feed.png" width=317>
|
||||
</p>
|
||||
|
||||
### Radio Stations
|
||||
If your server supports it - add a internet radio station feed
|
||||
<p align="left">
|
||||
<img src="mockup/usage/add_radio_station.png" width=326>
|
||||
</p>
|
||||
|
||||
## Navigation
|
||||
|
||||
@@ -137,10 +189,10 @@ This app works with any service that implements the Subsonic API, including:
|
||||
|
||||
### Support
|
||||
For additional help:
|
||||
- Question? Start a [Discussion](https://github.com/eddyizm/tempo/discussions)
|
||||
- Open an [issue](https://github.com/eddyizm/tempo/issues) if you don't find a discussion solving your issue.
|
||||
- Question? Start a [Discussion](https://github.com/eddyizm/tempus/discussions)
|
||||
- Open an [issue](https://github.com/eddyizm/tempus/issues) if you don't find a discussion solving your issue.
|
||||
- Consult your Subsonic server's documentation
|
||||
|
||||
---
|
||||
|
||||
*Note: This app requires a pre-existing Subsonic-compatible server with music content.*
|
||||
*Note: This app requires a pre-existing Subsonic-compatible server with music content.*
|
||||
|
||||
@@ -10,8 +10,8 @@ android {
|
||||
minSdkVersion 24
|
||||
targetSdk 35
|
||||
|
||||
versionCode 33
|
||||
versionName '3.16.0'
|
||||
versionCode 10
|
||||
versionName '4.5.0'
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
|
||||
javaCompileOptions {
|
||||
@@ -35,25 +35,26 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dependenciesInfo {
|
||||
// Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid)
|
||||
includeInApk = false
|
||||
// Disables dependency metadata when building Android App Bundles (for Google Play)
|
||||
includeInBundle = false
|
||||
}
|
||||
|
||||
flavorDimensions += "default"
|
||||
|
||||
productFlavors {
|
||||
tempo {
|
||||
tempus {
|
||||
dimension = "default"
|
||||
applicationId 'com.cappielloantonio.tempo'
|
||||
applicationId 'com.eddyizm.tempus'
|
||||
}
|
||||
|
||||
notquitemy {
|
||||
degoogled {
|
||||
dimension = "default"
|
||||
applicationId "com.cappielloantonio.notquitemy.tempo"
|
||||
applicationId "com.eddyizm.degoogled.tempus"
|
||||
}
|
||||
|
||||
play {
|
||||
dimension = "default"
|
||||
applicationId "com.cappielloantonio.play.tempo"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -109,13 +110,13 @@ dependencies {
|
||||
implementation 'com.github.bumptech.glide:annotations:4.16.0'
|
||||
|
||||
// Media3
|
||||
implementation 'androidx.media3:media3-session:1.5.1'
|
||||
implementation 'androidx.media3:media3-common:1.5.1'
|
||||
implementation 'androidx.media3:media3-exoplayer:1.5.1'
|
||||
implementation 'androidx.media3:media3-ui:1.5.1'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.5.1'
|
||||
tempoImplementation 'androidx.media3:media3-cast:1.5.1'
|
||||
playImplementation 'androidx.media3:media3-cast:1.5.1'
|
||||
implementation 'androidx.media3:media3-session:1.8.0'
|
||||
implementation 'androidx.media3:media3-common:1.8.0'
|
||||
implementation 'androidx.media3:media3-exoplayer:1.8.0'
|
||||
implementation 'androidx.media3:media3-ui:1.8.0'
|
||||
implementation 'androidx.media3:media3-exoplayer-hls:1.8.0'
|
||||
tempusImplementation 'androidx.media3:media3-cast:1.8.0'
|
||||
|
||||
|
||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
|
||||
annotationProcessor 'androidx.room:room-compiler:2.6.1'
|
||||
@@ -129,4 +130,4 @@ java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1158
app/schemas/com.cappielloantonio.tempo.database.AppDatabase/13.json
Normal file
BIN
app/src/degoogled/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,6 @@
|
||||
package com.cappielloantonio.tempo.service
|
||||
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
|
||||
@UnstableApi
|
||||
class MediaService : BaseMediaService()
|
||||
54
app/src/degoogled/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group android:scaleX="0.49"
|
||||
android:scaleY="0.49"
|
||||
android:translateX="130.56"
|
||||
android:translateY="130.56">
|
||||
|
||||
<path
|
||||
android:pathData="M512,437.33c0,11.78 -9.56,21.34 -21.34,21.34H21.33C9.55,458.67 0,449.11 0,437.33V96c0,-11.78 9.55,-21.33 21.33,-21.33h469.33c11.78,0 21.34,9.55 21.34,21.33L512,437.33L512,437.33z"
|
||||
android:fillColor="#8CC152"/> <path
|
||||
android:pathData="M512,416.01c0,11.78 -9.56,21.31 -21.34,21.31H21.33C9.55,437.33 0,427.8 0,416.01V74.67c0,-11.78 9.55,-21.34 21.33,-21.34h469.33c11.78,0 21.34,9.56 21.34,21.34L512,416.01L512,416.01z"
|
||||
android:fillColor="#62A43B"/> <path
|
||||
android:pathData="M63.99,160c-5.89,0 -10.66,4.78 -10.66,10.67v149.34c0,5.88 4.77,10.66 10.66,10.66c5.89,0 10.67,-4.78 10.67,-10.66V170.67C74.66,164.78 69.88,160 63.99,160z"
|
||||
android:fillColor="#8CC152"/> <path
|
||||
android:pathData="M74.66,106.67c0,5.89 -4.78,10.66 -10.67,10.66c-5.89,0 -10.66,-4.77 -10.66,-10.66S58.1,96 63.99,96C69.88,96 74.66,100.78 74.66,106.67z"
|
||||
android:fillColor="#E6E9ED"/>
|
||||
<path
|
||||
android:pathData="M74.66,384.01c0,5.88 -4.78,10.66 -10.67,10.66c-5.89,0 -10.66,-4.78 -10.66,-10.66c0,-5.91 4.77,-10.69 10.66,-10.69C69.88,373.33 74.66,378.11 74.66,384.01z"
|
||||
android:fillColor="#E6E9ED"/>
|
||||
<path
|
||||
android:pathData="M448,123.73h-21.34v203.19l-40.31,50.41v0.02c-1.47,1.83 -2.34,4.14 -2.34,6.67c0,5.88 4.78,10.66 10.66,10.66c3.38,0 6.38,-1.56 8.33,-4h0.02l42.66,-53.34l0,0c1.47,-1.81 2.34,-4.13 2.34,-6.66V123.73z"
|
||||
android:fillColor="#E6E9ED"/>
|
||||
<path
|
||||
android:pathData="M437.33,149.33c-11.77,0 -21.33,-9.56 -21.33,-21.33s9.56,-21.33 21.33,-21.33s21.33,9.56 21.33,21.33S449.09,149.33 437.33,149.33z"
|
||||
android:fillColor="#E6E9ED"/>
|
||||
<path
|
||||
android:pathData="M437.33,96c-17.67,0 -32,14.33 -32,32s14.33,32 32,32s32,-14.33 32,-32S455,96 437.33,96zM437.33,138.67c-5.89,0 -10.67,-4.8 -10.67,-10.67c0,-5.88 4.78,-10.67 10.67,-10.67s10.67,4.8 10.67,10.67C448,133.88 443.22,138.67 437.33,138.67z"
|
||||
android:fillColor="#CCD1D9"/>
|
||||
<path
|
||||
android:pathData="M405.33,245.33c0,82.48 -66.86,149.34 -149.33,149.34c-82.47,0 -149.33,-66.86 -149.33,-149.34C106.66,162.86 173.52,96 255.99,96C338.47,96 405.33,162.86 405.33,245.33z"
|
||||
android:fillColor="#434A54"/>
|
||||
<path
|
||||
android:pathData="M266.66,149.33c0,-5.89 -4.77,-10.66 -10.67,-10.66c-58.91,0 -106.66,47.75 -106.66,106.65l0,0c0,5.89 4.77,10.67 10.67,10.67s10.67,-4.78 10.67,-10.67l0,0c0,-22.78 8.88,-44.22 24.99,-60.33c16.12,-16.13 37.55,-25 60.34,-25C261.89,160 266.66,155.22 266.66,149.33z"
|
||||
android:fillColor="#656D78"/>
|
||||
<path
|
||||
android:pathData="M352,234.67c-5.9,0 -10.67,4.77 -10.67,10.66l0,0c0,22.8 -8.88,44.23 -24.98,60.34c-16.13,16.13 -37.56,25 -60.35,25c-5.89,0 -10.66,4.78 -10.66,10.66c0,5.91 4.77,10.69 10.66,10.69c58.91,0 106.66,-47.77 106.66,-106.69C362.65,239.44 357.89,234.67 352,234.67z"
|
||||
android:fillColor="#656D78"/>
|
||||
<path
|
||||
android:pathData="M255.99,288.01c-23.52,0 -42.66,-19.16 -42.66,-42.69c0,-23.52 19.14,-42.66 42.66,-42.66c23.54,0 42.66,19.14 42.66,42.66C298.65,268.86 279.53,288.01 255.99,288.01z"
|
||||
android:fillColor="#FFCE54"/>
|
||||
<path
|
||||
android:pathData="M255.99,192c-29.45,0 -53.33,23.88 -53.33,53.33s23.88,53.34 53.33,53.34c29.46,0 53.34,-23.89 53.34,-53.34S285.45,192 255.99,192zM255.99,277.34c-17.64,0 -32,-14.36 -32,-32.02c0,-17.64 14.36,-32 32,-32c17.65,0 32.01,14.36 32.01,32C288,262.98 273.64,277.34 255.99,277.34z"
|
||||
android:fillColor="#F6BB42"/>
|
||||
<path
|
||||
android:pathData="M266.66,245.33c0,5.89 -4.77,10.67 -10.67,10.67c-5.89,0 -10.66,-4.78 -10.66,-10.67s4.77,-10.66 10.66,-10.66C261.89,234.67 266.66,239.44 266.66,245.33z"
|
||||
android:fillColor="#434A54"/>
|
||||
<path
|
||||
android:pathData="M74.66,234.67H53.33c-5.89,0 -10.66,4.77 -10.66,10.66s4.77,10.67 10.66,10.67h21.34c5.89,0 10.66,-4.78 10.66,-10.67S80.56,234.67 74.66,234.67z"
|
||||
android:fillColor="#434A54"/>
|
||||
</group>
|
||||
</vector>
|
||||
53
app/src/degoogled/res/drawable/ic_splash_logo.xml
Normal file
@@ -0,0 +1,53 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group android:scaleX="0.55"
|
||||
android:scaleY="0.55"
|
||||
android:translateX="150.56"
|
||||
android:translateY="150.56">
|
||||
<path
|
||||
android:pathData="M512,437.33c0,11.78 -9.56,21.34 -21.34,21.34H21.33C9.55,458.67 0,449.11 0,437.33V96c0,-11.78 9.55,-21.33 21.33,-21.33h469.33c11.78,0 21.34,9.55 21.34,21.33L512,437.33L512,437.33z"
|
||||
android:fillColor="#8CC152"/> <path
|
||||
android:pathData="M512,416.01c0,11.78 -9.56,21.31 -21.34,21.31H21.33C9.55,437.33 0,427.8 0,416.01V74.67c0,-11.78 9.55,-21.34 21.33,-21.34h469.33c11.78,0 21.34,9.56 21.34,21.34L512,416.01L512,416.01z"
|
||||
android:fillColor="#62A43B"/> <path
|
||||
android:pathData="M63.99,160c-5.89,0 -10.66,4.78 -10.66,10.67v149.34c0,5.88 4.77,10.66 10.66,10.66c5.89,0 10.67,-4.78 10.67,-10.66V170.67C74.66,164.78 69.88,160 63.99,160z"
|
||||
android:fillColor="#8CC152"/> <path
|
||||
android:pathData="M74.66,106.67c0,5.89 -4.78,10.66 -10.67,10.66c-5.89,0 -10.66,-4.77 -10.66,-10.66S58.1,96 63.99,96C69.88,96 74.66,100.78 74.66,106.67z"
|
||||
android:fillColor="#E6E9ED"/>
|
||||
<path
|
||||
android:pathData="M74.66,384.01c0,5.88 -4.78,10.66 -10.67,10.66c-5.89,0 -10.66,-4.78 -10.66,-10.66c0,-5.91 4.77,-10.69 10.66,-10.69C69.88,373.33 74.66,378.11 74.66,384.01z"
|
||||
android:fillColor="#E6E9ED"/>
|
||||
<path
|
||||
android:pathData="M448,123.73h-21.34v203.19l-40.31,50.41v0.02c-1.47,1.83 -2.34,4.14 -2.34,6.67c0,5.88 4.78,10.66 10.66,10.66c3.38,0 6.38,-1.56 8.33,-4h0.02l42.66,-53.34l0,0c1.47,-1.81 2.34,-4.13 2.34,-6.66V123.73z"
|
||||
android:fillColor="#E6E9ED"/>
|
||||
<path
|
||||
android:pathData="M437.33,149.33c-11.77,0 -21.33,-9.56 -21.33,-21.33s9.56,-21.33 21.33,-21.33s21.33,9.56 21.33,21.33S449.09,149.33 437.33,149.33z"
|
||||
android:fillColor="#E6E9ED"/>
|
||||
<path
|
||||
android:pathData="M437.33,96c-17.67,0 -32,14.33 -32,32s14.33,32 32,32s32,-14.33 32,-32S455,96 437.33,96zM437.33,138.67c-5.89,0 -10.67,-4.8 -10.67,-10.67c0,-5.88 4.78,-10.67 10.67,-10.67s10.67,4.8 10.67,10.67C448,133.88 443.22,138.67 437.33,138.67z"
|
||||
android:fillColor="#CCD1D9"/>
|
||||
<path
|
||||
android:pathData="M405.33,245.33c0,82.48 -66.86,149.34 -149.33,149.34c-82.47,0 -149.33,-66.86 -149.33,-149.34C106.66,162.86 173.52,96 255.99,96C338.47,96 405.33,162.86 405.33,245.33z"
|
||||
android:fillColor="#434A54"/>
|
||||
<path
|
||||
android:pathData="M266.66,149.33c0,-5.89 -4.77,-10.66 -10.67,-10.66c-58.91,0 -106.66,47.75 -106.66,106.65l0,0c0,5.89 4.77,10.67 10.67,10.67s10.67,-4.78 10.67,-10.67l0,0c0,-22.78 8.88,-44.22 24.99,-60.33c16.12,-16.13 37.55,-25 60.34,-25C261.89,160 266.66,155.22 266.66,149.33z"
|
||||
android:fillColor="#656D78"/>
|
||||
<path
|
||||
android:pathData="M352,234.67c-5.9,0 -10.67,4.77 -10.67,10.66l0,0c0,22.8 -8.88,44.23 -24.98,60.34c-16.13,16.13 -37.56,25 -60.35,25c-5.89,0 -10.66,4.78 -10.66,10.66c0,5.91 4.77,10.69 10.66,10.69c58.91,0 106.66,-47.77 106.66,-106.69C362.65,239.44 357.89,234.67 352,234.67z"
|
||||
android:fillColor="#656D78"/>
|
||||
<path
|
||||
android:pathData="M255.99,288.01c-23.52,0 -42.66,-19.16 -42.66,-42.69c0,-23.52 19.14,-42.66 42.66,-42.66c23.54,0 42.66,19.14 42.66,42.66C298.65,268.86 279.53,288.01 255.99,288.01z"
|
||||
android:fillColor="#FFCE54"/>
|
||||
<path
|
||||
android:pathData="M255.99,192c-29.45,0 -53.33,23.88 -53.33,53.33s23.88,53.34 53.33,53.34c29.46,0 53.34,-23.89 53.34,-53.34S285.45,192 255.99,192zM255.99,277.34c-17.64,0 -32,-14.36 -32,-32.02c0,-17.64 14.36,-32 32,-32c17.65,0 32.01,14.36 32.01,32C288,262.98 273.64,277.34 255.99,277.34z"
|
||||
android:fillColor="#F6BB42"/>
|
||||
<path
|
||||
android:pathData="M266.66,245.33c0,5.89 -4.77,10.67 -10.67,10.67c-5.89,0 -10.66,-4.78 -10.66,-10.67s4.77,-10.66 10.66,-10.66C261.89,234.67 266.66,239.44 266.66,245.33z"
|
||||
android:fillColor="#434A54"/>
|
||||
<path
|
||||
android:pathData="M74.66,234.67H53.33c-5.89,0 -10.66,4.77 -10.66,10.66s4.77,10.67 10.66,10.67h21.34c5.89,0 10.66,-4.78 10.66,-10.67S80.56,234.67 74.66,234.67z"
|
||||
android:fillColor="#434A54"/>
|
||||
</group>
|
||||
</vector>
|
||||
5
app/src/degoogled/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
app/src/degoogled/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
app/src/degoogled/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
app/src/degoogled/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
app/src/degoogled/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
app/src/degoogled/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
app/src/degoogled/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
app/src/degoogled/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
app/src/degoogled/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
app/src/degoogled/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
app/src/degoogled/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
4
app/src/degoogled/res/values/ic_launcher_background.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#626A75</color>
|
||||
</resources>
|
||||
@@ -42,6 +42,16 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="asset"
|
||||
android:scheme="tempo" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
@@ -30,7 +30,7 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
|
||||
|
||||
@UnstableApi
|
||||
@Database(
|
||||
version = 12,
|
||||
version = 13,
|
||||
entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class, LyricsCache.class},
|
||||
autoMigrations = {@AutoMigration(from = 10, to = 11), @AutoMigration(from = 11, to = 12)}
|
||||
)
|
||||
|
||||
@@ -12,9 +12,12 @@ import java.util.List;
|
||||
|
||||
@Dao
|
||||
public interface RecentSearchDao {
|
||||
@Query("SELECT * FROM recent_search ORDER BY search DESC")
|
||||
@Query("SELECT search FROM recent_search ORDER BY timestamp DESC")
|
||||
List<String> getRecent();
|
||||
|
||||
@Query("SELECT search FROM recent_search ORDER BY search DESC")
|
||||
List<String> getAlpha();
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insert(RecentSearch search);
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package com.cappielloantonio.tempo.github;
|
||||
import com.cappielloantonio.tempo.github.api.release.ReleaseClient;
|
||||
|
||||
public class Github {
|
||||
private static final String OWNER = "CappielloAntonio";
|
||||
private static final String REPO = "Tempo";
|
||||
private static final String OWNER = "eddyizm";
|
||||
private static final String REPO = "Tempus";
|
||||
private ReleaseClient releaseClient;
|
||||
|
||||
public ReleaseClient getReleaseClient() {
|
||||
|
||||
@@ -7,10 +7,11 @@ public class UpdateUtil {
|
||||
|
||||
public static boolean showUpdateDialog(LatestRelease release) {
|
||||
if (release.getTagName() == null) return false;
|
||||
String remoteTag = release.getTagName().replaceAll("^\\D+", "");
|
||||
|
||||
try {
|
||||
String[] local = BuildConfig.VERSION_NAME.split("\\.");
|
||||
String[] remote = release.getTagName().split("\\.");
|
||||
String[] remote = remoteTag.split("\\.");
|
||||
|
||||
for (int i = 0; i < local.length; i++) {
|
||||
int localPart = Integer.parseInt(local[i]);
|
||||
|
||||
@@ -4,14 +4,18 @@ import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.bumptech.glide.Glide;
|
||||
import com.bumptech.glide.GlideBuilder;
|
||||
import com.bumptech.glide.annotation.GlideModule;
|
||||
import com.bumptech.glide.load.DecodeFormat;
|
||||
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory;
|
||||
import com.bumptech.glide.Registry;
|
||||
import com.bumptech.glide.module.AppGlideModule;
|
||||
import com.bumptech.glide.request.RequestOptions;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
@GlideModule
|
||||
public class CustomGlideModule extends AppGlideModule {
|
||||
@Override
|
||||
@@ -20,4 +24,9 @@ public class CustomGlideModule extends AppGlideModule {
|
||||
builder.setDiskCache(new InternalCacheDiskCacheFactory(context, "cache", diskCacheSize));
|
||||
builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
|
||||
registry.replace(String.class, InputStream.class, new IPv6StringLoader.Factory());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ public class CustomGlideRequest {
|
||||
|
||||
public static class Builder {
|
||||
private final RequestManager requestManager;
|
||||
private Object item;
|
||||
private String item;
|
||||
|
||||
private Builder(Context context, String item, ResourceType type) {
|
||||
this.requestManager = Glide.with(context);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.cappielloantonio.tempo.glide;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.bumptech.glide.Priority;
|
||||
import com.bumptech.glide.load.DataSource;
|
||||
import com.bumptech.glide.load.Options;
|
||||
import com.bumptech.glide.load.data.DataFetcher;
|
||||
import com.bumptech.glide.load.model.ModelLoader;
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory;
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
|
||||
import com.bumptech.glide.signature.ObjectKey;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
|
||||
public class IPv6StringLoader implements ModelLoader<String, InputStream> {
|
||||
private static final int DEFAULT_TIMEOUT_MS = 2500;
|
||||
|
||||
@Override
|
||||
public boolean handles(@NonNull String model) {
|
||||
return model.startsWith("http://") || model.startsWith("https://");
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoadData<InputStream> buildLoadData(@NonNull String model, int width, int height, @NonNull Options options) {
|
||||
if (!handles(model)) {
|
||||
return null;
|
||||
}
|
||||
return new LoadData<>(new ObjectKey(model), new IPv6StreamFetcher(model));
|
||||
}
|
||||
|
||||
private static class IPv6StreamFetcher implements DataFetcher<InputStream> {
|
||||
private final String model;
|
||||
private InputStream stream;
|
||||
private HttpURLConnection connection;
|
||||
|
||||
IPv6StreamFetcher(String model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) {
|
||||
try {
|
||||
URL url = new URL(model);
|
||||
connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setConnectTimeout(DEFAULT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(DEFAULT_TIMEOUT_MS);
|
||||
connection.setUseCaches(true);
|
||||
connection.setDoInput(true);
|
||||
connection.connect();
|
||||
|
||||
if (connection.getResponseCode() / 100 != 2) {
|
||||
callback.onLoadFailed(new IOException("Request failed with status code: " + connection.getResponseCode()));
|
||||
return;
|
||||
}
|
||||
|
||||
stream = connection.getInputStream();
|
||||
callback.onDataReady(stream);
|
||||
} catch (IOException e) {
|
||||
callback.onLoadFailed(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup() {
|
||||
if (stream != null) {
|
||||
try {
|
||||
stream.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancel() {
|
||||
// HttpURLConnection does not provide a direct cancel mechanism.
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Class<InputStream> getDataClass() {
|
||||
return InputStream.class;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public DataSource getDataSource() {
|
||||
return DataSource.REMOTE;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Factory implements ModelLoaderFactory<String, InputStream> {
|
||||
@NonNull
|
||||
@Override
|
||||
public ModelLoader<String, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) {
|
||||
return new IPv6StringLoader();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void teardown() {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,11 @@ public interface ClickCallback {
|
||||
default void onInternetRadioStationClick(Bundle bundle) {}
|
||||
default void onInternetRadioStationLongClick(Bundle bundle) {}
|
||||
default void onMusicFolderClick(Bundle bundle) {}
|
||||
default void onMusicFolderPlay(Bundle bundle) {}
|
||||
default void onMusicDirectoryClick(Bundle bundle) {}
|
||||
default void onMusicDirectoryPlay(Bundle bundle) {}
|
||||
default void onMusicIndexClick(Bundle bundle) {}
|
||||
default void onMusicIndexPlay(Bundle bundle) {}
|
||||
default void onDownloadGroupLongClick(Bundle bundle) {}
|
||||
default void onShareClick(Bundle bundle) {}
|
||||
default void onShareLongClick(Bundle bundle) {}
|
||||
|
||||
@@ -8,18 +8,18 @@ import androidx.room.PrimaryKey
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child
|
||||
import com.cappielloantonio.tempo.util.Preferences
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
@Entity(tableName = "chronology")
|
||||
class Chronology(@PrimaryKey override val id: String) : Child(id) {
|
||||
class Chronology(
|
||||
@PrimaryKey override val id: String,
|
||||
@ColumnInfo(name = "timestamp")
|
||||
var timestamp: Long = System.currentTimeMillis()
|
||||
|
||||
var timestamp: Long = System.currentTimeMillis(),
|
||||
@ColumnInfo(name = "server")
|
||||
var server: String? = null
|
||||
|
||||
var server: String? = null,
|
||||
) : Child(id) {
|
||||
constructor(mediaItem: MediaItem) : this(mediaItem.mediaMetadata.extras!!.getString("id")!!) {
|
||||
parentId = mediaItem.mediaMetadata.extras!!.getString("parentId")
|
||||
isDir = mediaItem.mediaMetadata.extras!!.getBoolean("isDir")
|
||||
|
||||
@@ -10,19 +10,17 @@ import kotlinx.parcelize.Parcelize
|
||||
@Keep
|
||||
@Parcelize
|
||||
@Entity(tableName = "download")
|
||||
class Download(@PrimaryKey override val id: String) : Child(id) {
|
||||
class Download(
|
||||
@PrimaryKey override val id: String,
|
||||
@ColumnInfo(name = "playlist_id")
|
||||
var playlistId: String? = null
|
||||
|
||||
var playlistId: String? = null,
|
||||
@ColumnInfo(name = "playlist_name")
|
||||
var playlistName: String? = null
|
||||
|
||||
var playlistName: String? = null,
|
||||
@ColumnInfo(name = "download_state", defaultValue = "1")
|
||||
var downloadState: Int = 0
|
||||
|
||||
var downloadState: Int = 0,
|
||||
@ColumnInfo(name = "download_uri", defaultValue = "")
|
||||
var downloadUri: String? = null
|
||||
|
||||
var downloadUri: String? = null,
|
||||
) : Child(id) {
|
||||
constructor(child: Child) : this(child.id) {
|
||||
parentId = child.parentId
|
||||
isDir = child.isDir
|
||||
@@ -40,6 +38,8 @@ class Download(@PrimaryKey override val id: String) : Child(id) {
|
||||
transcodedSuffix = child.transcodedSuffix
|
||||
duration = child.duration
|
||||
bitrate = child.bitrate
|
||||
samplingRate = child.samplingRate
|
||||
bitDepth = child.bitDepth
|
||||
path = child.path
|
||||
isVideo = child.isVideo
|
||||
userRating = child.userRating
|
||||
@@ -60,5 +60,5 @@ class Download(@PrimaryKey override val id: String) : Child(id) {
|
||||
@Keep
|
||||
data class DownloadStack(
|
||||
var id: String,
|
||||
var view: String?
|
||||
var view: String?,
|
||||
)
|
||||
@@ -10,20 +10,18 @@ import kotlinx.parcelize.Parcelize
|
||||
@Keep
|
||||
@Parcelize
|
||||
@Entity(tableName = "queue")
|
||||
class Queue(override val id: String) : Child(id) {
|
||||
class Queue(
|
||||
override val id: String,
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "track_order")
|
||||
var trackOrder: Int = 0
|
||||
|
||||
var trackOrder: Int = 0,
|
||||
@ColumnInfo(name = "last_play")
|
||||
var lastPlay: Long = 0
|
||||
|
||||
var lastPlay: Long = 0,
|
||||
@ColumnInfo(name = "playing_changed")
|
||||
var playingChanged: Long = 0
|
||||
|
||||
var playingChanged: Long = 0,
|
||||
@ColumnInfo(name = "stream_id")
|
||||
var streamId: String? = null
|
||||
|
||||
var streamId: String? = null,
|
||||
) : Child(id) {
|
||||
constructor(child: Child) : this(child.id) {
|
||||
parentId = child.parentId
|
||||
isDir = child.isDir
|
||||
|
||||
@@ -13,5 +13,8 @@ import kotlinx.parcelize.Parcelize
|
||||
data class RecentSearch(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "search")
|
||||
var search: String
|
||||
var search: String,
|
||||
|
||||
@ColumnInfo(name = "timestamp", defaultValue = "0")
|
||||
var timestamp: Long
|
||||
) : Parcelable
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.repository;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import android.util.Log;
|
||||
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.interfaces.DecadesCallback;
|
||||
@@ -31,14 +32,22 @@ public class AlbumRepository {
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getAlbumList2() != null && response.body().getSubsonicResponse().getAlbumList2().getAlbums() != null) {
|
||||
if (response.isSuccessful()
|
||||
&& response.body() != null
|
||||
&& response.body().getSubsonicResponse().getAlbumList2() != null
|
||||
&& response.body().getSubsonicResponse().getAlbumList2().getAlbums() != null) {
|
||||
|
||||
listLiveAlbums.setValue(response.body().getSubsonicResponse().getAlbumList2().getAlbums());
|
||||
} else {
|
||||
Log.e("AlbumRepository", "API Error on getAlbums. HTTP Code: " + response.code());
|
||||
listLiveAlbums.setValue(new ArrayList<>());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
Log.e("AlbumRepository", "Network Failure on getAlbums: " + t.getMessage());
|
||||
listLiveAlbums.setValue(new ArrayList<>());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -196,6 +205,8 @@ public class AlbumRepository {
|
||||
}
|
||||
|
||||
public void getInstantMix(AlbumID3 album, int count, MediaCallback callback) {
|
||||
Log.d("AlbumRepository", "Attempting getInstantMix for AlbumID: " + album.getId());
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBrowsingClient()
|
||||
.getSimilarSongs2(album.getId(), count)
|
||||
@@ -204,8 +215,17 @@ public class AlbumRepository {
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
List<Child> songs = new ArrayList<>();
|
||||
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSimilarSongs2() != null) {
|
||||
songs.addAll(response.body().getSubsonicResponse().getSimilarSongs2().getSongs());
|
||||
if (response.isSuccessful()
|
||||
&& response.body() != null
|
||||
&& response.body().getSubsonicResponse().getSimilarSongs2() != null) {
|
||||
|
||||
List<Child> similarSongs = response.body().getSubsonicResponse().getSimilarSongs2().getSongs();
|
||||
|
||||
if (similarSongs == null) {
|
||||
Log.w("AlbumRepository", "API successful but 'songs' list was NULL for AlbumID: " + album.getId());
|
||||
} else {
|
||||
songs.addAll(similarSongs);
|
||||
}
|
||||
}
|
||||
|
||||
callback.onLoadMedia(songs);
|
||||
@@ -289,4 +309,4 @@ public class AlbumRepository {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,11 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.IndexID3;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
@@ -312,24 +314,42 @@ public class ArtistRepository {
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBrowsingClient()
|
||||
.getTopSongs(artist.getName(), count)
|
||||
.getArtist(artist.getId())
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getTopSongs() != null && response.body().getSubsonicResponse().getTopSongs().getSongs() != null) {
|
||||
List<Child> songs = response.body().getSubsonicResponse().getTopSongs().getSongs();
|
||||
if (response.isSuccessful() && response.body() != null &&
|
||||
response.body().getSubsonicResponse().getArtist() != null &&
|
||||
response.body().getSubsonicResponse().getArtist().getAlbums() != null) {
|
||||
|
||||
if (songs != null && !songs.isEmpty()) {
|
||||
Collections.shuffle(songs);
|
||||
List<AlbumID3> albums = response.body().getSubsonicResponse().getArtist().getAlbums();
|
||||
Log.d("ArtistRepository", "Got albums directly: " + albums.size());
|
||||
if (albums.isEmpty()) {
|
||||
Log.d("ArtistRepository", "No albums found in artist response");
|
||||
return;
|
||||
}
|
||||
|
||||
randomSongs.setValue(songs);
|
||||
Collections.shuffle(albums);
|
||||
int[] counts = albums.stream().mapToInt(AlbumID3::getSongCount).toArray();
|
||||
Arrays.parallelPrefix(counts, Integer::sum);
|
||||
int albumLimit = 0;
|
||||
int multiplier = 4; // get more than the limit so we can shuffle them
|
||||
while (albumLimit < albums.size() && counts[albumLimit] < count * multiplier)
|
||||
albumLimit++;
|
||||
Log.d("ArtistRepository", String.format("Retaining %d/%d albums", albumLimit, albums.size()));
|
||||
|
||||
fetchAllAlbumSongsWithCallback(albums.stream().limit(albumLimit).collect(Collectors.toList()), songs -> {
|
||||
Collections.shuffle(songs);
|
||||
randomSongs.setValue(songs.stream().limit(count).collect(Collectors.toList()));
|
||||
});
|
||||
} else {
|
||||
Log.d("ArtistRepository", "Failed to get artist info");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
Log.d("ArtistRepository", "Error getting artist info: " + t.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -80,6 +80,33 @@ public class PlaylistRepository {
|
||||
return listLivePlaylistSongs;
|
||||
}
|
||||
|
||||
public MutableLiveData<Playlist> getPlaylist(String id) {
|
||||
MutableLiveData<Playlist> playlistLiveData = new MutableLiveData<>();
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
.getPlaylist(id)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful()
|
||||
&& response.body() != null
|
||||
&& response.body().getSubsonicResponse().getPlaylist() != null) {
|
||||
playlistLiveData.setValue(response.body().getSubsonicResponse().getPlaylist());
|
||||
} else {
|
||||
playlistLiveData.setValue(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
playlistLiveData.setValue(null);
|
||||
}
|
||||
});
|
||||
|
||||
return playlistLiveData;
|
||||
}
|
||||
|
||||
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) {
|
||||
if (songsId.isEmpty()) {
|
||||
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show();
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.cappielloantonio.tempo.repository;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.database.AppDatabase;
|
||||
@@ -52,6 +55,8 @@ public class QueueRepository {
|
||||
public MutableLiveData<PlayQueue> getPlayQueue() {
|
||||
MutableLiveData<PlayQueue> playQueue = new MutableLiveData<>();
|
||||
|
||||
Log.d(TAG, "Getting play queue from server...");
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBookmarksClient()
|
||||
.getPlayQueue()
|
||||
@@ -59,12 +64,19 @@ public class QueueRepository {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlayQueue() != null) {
|
||||
playQueue.setValue(response.body().getSubsonicResponse().getPlayQueue());
|
||||
PlayQueue serverQueue = response.body().getSubsonicResponse().getPlayQueue();
|
||||
Log.d(TAG, "Server returned play queue with " +
|
||||
(serverQueue.getEntries() != null ? serverQueue.getEntries().size() : 0) + " items");
|
||||
playQueue.setValue(serverQueue);
|
||||
} else {
|
||||
Log.d(TAG, "Server returned no play queue");
|
||||
playQueue.setValue(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
Log.e(TAG, "Failed to get play queue", t);
|
||||
playQueue.setValue(null);
|
||||
}
|
||||
});
|
||||
@@ -73,18 +85,24 @@ public class QueueRepository {
|
||||
}
|
||||
|
||||
public void savePlayQueue(List<String> ids, String current, long position) {
|
||||
Log.d(TAG, "Saving play queue to server - Items: " + ids.size() + ", Current: " + current);
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getBookmarksClient()
|
||||
.savePlayQueue(ids, current, position)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
if (response.isSuccessful()) {
|
||||
Log.d(TAG, "Play queue saved successfully");
|
||||
} else {
|
||||
Log.d(TAG, "Play queue save failed with code: " + response.code());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
Log.e(TAG, "Play queue save failed", t);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -121,6 +139,14 @@ public class QueueRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isMediaInQueue(List<Queue> queue, Child media) {
|
||||
if (queue == null || media == null) return false;
|
||||
return queue.stream().anyMatch(queueItem ->
|
||||
queueItem != null && media.getId() != null &&
|
||||
queueItem.getId().equals(media.getId())
|
||||
);
|
||||
}
|
||||
|
||||
public void insertAll(List<Child> toAdd, boolean reset, int afterIndex) {
|
||||
try {
|
||||
List<Queue> media = new ArrayList<>();
|
||||
@@ -134,8 +160,14 @@ public class QueueRepository {
|
||||
media = getMediaThreadSafe.getMedia();
|
||||
}
|
||||
|
||||
for (int i = 0; i < toAdd.size(); i++) {
|
||||
Queue queueItem = new Queue(toAdd.get(i));
|
||||
List<Child> filteredToAdd = toAdd;
|
||||
final List<Queue> finalMedia = media;
|
||||
filteredToAdd = toAdd.stream()
|
||||
.filter(child -> !isMediaInQueue(finalMedia, child))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
for (int i = 0; i < filteredToAdd.size(); i++) {
|
||||
Queue queueItem = new Queue(filteredToAdd.get(i));
|
||||
media.add(afterIndex + i, queueItem);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.SearchResult2;
|
||||
import com.cappielloantonio.tempo.subsonic.models.SearchResult3;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -186,7 +187,12 @@ public class SearchingRepository {
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
recent = recentSearchDao.getRecent();
|
||||
if(Preferences.isSearchSortingChronologicallyEnabled()){
|
||||
recent = recentSearchDao.getRecent();
|
||||
}
|
||||
else {
|
||||
recent = recentSearchDao.getAlpha();
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getRecent() {
|
||||
|
||||
@@ -100,6 +100,33 @@ public class SongRepository {
|
||||
return randomSongsSample;
|
||||
}
|
||||
|
||||
public MutableLiveData<List<Child>> getRandomSampleWithGenre(int number, Integer fromYear, Integer toYear, String genre) {
|
||||
MutableLiveData<List<Child>> randomSongsSample = new MutableLiveData<>();
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getAlbumSongListClient()
|
||||
.getRandomSongs(number, fromYear, toYear, genre)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
List<Child> songs = new ArrayList<>();
|
||||
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null && response.body().getSubsonicResponse().getRandomSongs().getSongs() != null) {
|
||||
songs.addAll(response.body().getSubsonicResponse().getRandomSongs().getSongs());
|
||||
}
|
||||
|
||||
randomSongsSample.setValue(songs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
return randomSongsSample;
|
||||
}
|
||||
|
||||
public void scrobble(String id, boolean submission) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getMediaAnnotationClient()
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
package com.cappielloantonio.tempo.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent.FLAG_IMMUTABLE
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.app.TaskStackBuilder
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.media3.common.*
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
|
||||
import androidx.media3.session.*
|
||||
import androidx.media3.session.MediaSession.ControllerInfo
|
||||
import com.cappielloantonio.tempo.R
|
||||
import com.cappielloantonio.tempo.repository.QueueRepository
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity
|
||||
import com.cappielloantonio.tempo.util.*
|
||||
import com.cappielloantonio.tempo.widget.WidgetUpdateManager
|
||||
import com.google.common.collect.ImmutableList
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
|
||||
@UnstableApi
|
||||
open class BaseMediaService : MediaLibraryService() {
|
||||
companion object {
|
||||
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
|
||||
"android.media3.session.demo.SHUFFLE_ON"
|
||||
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF =
|
||||
"android.media3.session.demo.SHUFFLE_OFF"
|
||||
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF =
|
||||
"android.media3.session.demo.REPEAT_OFF"
|
||||
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE =
|
||||
"android.media3.session.demo.REPEAT_ONE"
|
||||
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL =
|
||||
"android.media3.session.demo.REPEAT_ALL"
|
||||
const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER"
|
||||
const val ACTION_EQUALIZER_UPDATED = "com.cappielloantonio.tempo.service.EQUALIZER_UPDATED"
|
||||
}
|
||||
|
||||
protected lateinit var exoplayer: ExoPlayer
|
||||
protected lateinit var mediaLibrarySession: MediaLibrarySession
|
||||
private lateinit var networkCallback: CustomNetworkCallback
|
||||
private lateinit var equalizerManager: EqualizerManager
|
||||
private val widgetUpdateHandler = Handler(Looper.getMainLooper())
|
||||
private var widgetUpdateScheduled = false
|
||||
private val widgetUpdateRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
val player = mediaLibrarySession.player
|
||||
if (!player.isPlaying) {
|
||||
widgetUpdateScheduled = false
|
||||
return
|
||||
}
|
||||
updateWidget(player)
|
||||
widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private val binder = LocalBinder()
|
||||
|
||||
open fun playerInitHook() {
|
||||
initializeExoPlayer()
|
||||
initializeMediaLibrarySession(exoplayer)
|
||||
initializePlayerListener(exoplayer)
|
||||
setPlayer(null, exoplayer)
|
||||
}
|
||||
|
||||
open fun getMediaLibrarySessionCallback(): MediaLibrarySession.Callback {
|
||||
return CustomMediaLibrarySessionCallback(baseContext)
|
||||
}
|
||||
|
||||
fun updateMediaItems(player: Player) {
|
||||
Log.d(javaClass.toString(), "update items")
|
||||
val n = player.mediaItemCount
|
||||
val k = player.currentMediaItemIndex
|
||||
val current = player.currentPosition
|
||||
val items = (0..n - 1).map { MappingUtil.mapMediaItem(player.getMediaItemAt(it)) }
|
||||
player.clearMediaItems()
|
||||
player.setMediaItems(items, k, current)
|
||||
}
|
||||
|
||||
fun restorePlayerFromQueue(player: Player) {
|
||||
if (player.mediaItemCount > 0) return
|
||||
|
||||
val queueRepository = QueueRepository()
|
||||
val storedQueue = queueRepository.media
|
||||
if (storedQueue.isNullOrEmpty()) return
|
||||
|
||||
val mediaItems = MappingUtil.mapMediaItems(storedQueue)
|
||||
if (mediaItems.isEmpty()) return
|
||||
|
||||
val lastIndex = try {
|
||||
queueRepository.lastPlayedMediaIndex
|
||||
} catch (_: Exception) {
|
||||
0
|
||||
}.coerceIn(0, mediaItems.size - 1)
|
||||
|
||||
val lastPosition = try {
|
||||
queueRepository.lastPlayedMediaTimestamp
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}.let { if (it < 0L) 0L else it }
|
||||
|
||||
player.setMediaItems(mediaItems, lastIndex, lastPosition)
|
||||
player.prepare()
|
||||
updateWidget(player)
|
||||
}
|
||||
|
||||
fun initializePlayerListener(player: Player) {
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
Log.d(javaClass.toString(), "onMediaItemTransition" + player.currentMediaItemIndex)
|
||||
if (mediaItem == null) return
|
||||
|
||||
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
|
||||
MediaManager.setLastPlayedTimestamp(mediaItem)
|
||||
}
|
||||
updateWidget(player)
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
Log.d(javaClass.toString(), "onTracksChanged " + player.currentMediaItemIndex)
|
||||
ReplayGainUtil.setReplayGain(player, tracks)
|
||||
val currentMediaItem = player.currentMediaItem
|
||||
if (currentMediaItem != null) {
|
||||
val item = MappingUtil.mapMediaItem(currentMediaItem)
|
||||
if (item.mediaMetadata.extras != null)
|
||||
MediaManager.scrobble(item, false)
|
||||
|
||||
if (player.nextMediaItemIndex == C.INDEX_UNSET)
|
||||
MediaManager.continuousPlay(player.currentMediaItem)
|
||||
}
|
||||
|
||||
if (player is ExoPlayer) {
|
||||
// https://stackoverflow.com/questions/56937283/exoplayer-shuffle-doesnt-reproduce-all-the-songs
|
||||
if (MediaManager.justStarted.get()) {
|
||||
Log.d(javaClass.toString(), "update shuffle order")
|
||||
MediaManager.justStarted.set(false)
|
||||
val shuffledList = IntArray(player.mediaItemCount) { i -> i }
|
||||
shuffledList.shuffle()
|
||||
val index = shuffledList.indexOf(player.currentMediaItemIndex)
|
||||
// swap current media index to the first index
|
||||
if (index > -1 && shuffledList.isNotEmpty()) {
|
||||
val tmp = shuffledList[0]
|
||||
shuffledList[0] = shuffledList[index]
|
||||
shuffledList[index] = tmp
|
||||
}
|
||||
player.shuffleOrder =
|
||||
DefaultShuffleOrder(shuffledList, kotlin.random.Random.nextLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
Log.d(javaClass.toString(), "onIsPlayingChanged " + player.currentMediaItemIndex)
|
||||
if (!isPlaying) {
|
||||
MediaManager.setPlayingPausedTimestamp(
|
||||
player.currentMediaItem,
|
||||
player.currentPosition
|
||||
)
|
||||
} else {
|
||||
MediaManager.scrobble(player.currentMediaItem, false)
|
||||
}
|
||||
if (isPlaying) {
|
||||
scheduleWidgetUpdates()
|
||||
} else {
|
||||
stopWidgetUpdates()
|
||||
}
|
||||
updateWidget(player)
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
Log.d(javaClass.toString(), "onPlaybackStateChanged")
|
||||
super.onPlaybackStateChanged(playbackState)
|
||||
if (!player.hasNextMediaItem() &&
|
||||
playbackState == Player.STATE_ENDED &&
|
||||
player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
|
||||
) {
|
||||
MediaManager.scrobble(player.currentMediaItem, true)
|
||||
MediaManager.saveChronology(player.currentMediaItem)
|
||||
}
|
||||
updateWidget(player)
|
||||
}
|
||||
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
Log.d(javaClass.toString(), "onPositionDiscontinuity")
|
||||
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
|
||||
|
||||
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) {
|
||||
if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
|
||||
MediaManager.scrobble(oldPosition.mediaItem, true)
|
||||
MediaManager.saveChronology(oldPosition.mediaItem)
|
||||
}
|
||||
|
||||
if (newPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
|
||||
MediaManager.setLastPlayedTimestamp(newPosition.mediaItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
|
||||
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
|
||||
}
|
||||
|
||||
override fun onRepeatModeChanged(repeatMode: Int) {
|
||||
Preferences.setRepeatMode(repeatMode)
|
||||
}
|
||||
|
||||
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
||||
Log.d(javaClass.toString(), "onAudioSessionIdChanged")
|
||||
attachEqualizerIfPossible(audioSessionId)
|
||||
}
|
||||
})
|
||||
if (player.isPlaying) {
|
||||
scheduleWidgetUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
fun setPlayer(oldPlayer: Player?, newPlayer: Player) {
|
||||
if (oldPlayer === newPlayer) return
|
||||
if (oldPlayer != null) {
|
||||
val currentQueue = getQueueFromPlayer(oldPlayer)
|
||||
val currentIndex = oldPlayer.currentMediaItemIndex
|
||||
val currentPosition = oldPlayer.currentPosition
|
||||
val isPlaying = oldPlayer.playWhenReady
|
||||
oldPlayer.stop()
|
||||
newPlayer.setMediaItems(currentQueue, currentIndex, currentPosition)
|
||||
newPlayer.playWhenReady = isPlaying
|
||||
newPlayer.prepare()
|
||||
}
|
||||
mediaLibrarySession.player = newPlayer
|
||||
}
|
||||
|
||||
open fun releasePlayers() {
|
||||
exoplayer.release()
|
||||
}
|
||||
|
||||
fun getQueueFromPlayer(player: Player): List<MediaItem> {
|
||||
return (0..player.mediaItemCount - 1).map(player::getMediaItemAt)
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val player = mediaLibrarySession.player
|
||||
|
||||
if (!player.playWhenReady || player.mediaItemCount == 0) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
playerInitHook()
|
||||
initializeEqualizerManager()
|
||||
initializeNetworkListener()
|
||||
restorePlayerFromQueue(mediaLibrarySession.player)
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
|
||||
return mediaLibrarySession
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
releaseNetworkCallback()
|
||||
equalizerManager.release()
|
||||
stopWidgetUpdates()
|
||||
releasePlayers()
|
||||
mediaLibrarySession.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
// Check if the intent is for our custom equalizer binder
|
||||
if (intent?.action == ACTION_BIND_EQUALIZER) {
|
||||
return binder
|
||||
}
|
||||
// Otherwise, handle it as a normal MediaLibraryService connection
|
||||
return super.onBind(intent)
|
||||
}
|
||||
|
||||
private fun initializeExoPlayer() {
|
||||
exoplayer = ExoPlayer.Builder(this)
|
||||
.setRenderersFactory(getRenderersFactory())
|
||||
.setMediaSourceFactory(getMediaSourceFactory())
|
||||
.setAudioAttributes(AudioAttributes.DEFAULT, true)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.setWakeMode(C.WAKE_MODE_NETWORK)
|
||||
.setLoadControl(initializeLoadControl())
|
||||
.build()
|
||||
|
||||
exoplayer.shuffleModeEnabled = Preferences.isShuffleModeEnabled()
|
||||
exoplayer.repeatMode = Preferences.getRepeatMode()
|
||||
}
|
||||
|
||||
private fun initializeEqualizerManager() {
|
||||
equalizerManager = EqualizerManager()
|
||||
val audioSessionId = exoplayer.audioSessionId
|
||||
attachEqualizerIfPossible(audioSessionId)
|
||||
}
|
||||
|
||||
private fun initializeMediaLibrarySession(player: Player) {
|
||||
Log.d(javaClass.toString(), "initializeMediaLibrarySession")
|
||||
val sessionActivityPendingIntent =
|
||||
TaskStackBuilder.create(this).run {
|
||||
addNextIntent(Intent(baseContext, MainActivity::class.java))
|
||||
getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
mediaLibrarySession =
|
||||
MediaLibrarySession.Builder(this, player, getMediaLibrarySessionCallback())
|
||||
.setSessionActivity(sessionActivityPendingIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun initializeNetworkListener() {
|
||||
networkCallback = CustomNetworkCallback()
|
||||
getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(
|
||||
networkCallback
|
||||
)
|
||||
updateMediaItems(mediaLibrarySession.player)
|
||||
}
|
||||
|
||||
private fun initializeLoadControl(): DefaultLoadControl {
|
||||
return DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
(DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
|
||||
(DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
|
||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
|
||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun releaseNetworkCallback() {
|
||||
getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback)
|
||||
}
|
||||
|
||||
private fun updateWidget(player: Player) {
|
||||
val mi = player.currentMediaItem
|
||||
val title = mi?.mediaMetadata?.title?.toString()
|
||||
?: mi?.mediaMetadata?.extras?.getString("title")
|
||||
val artist = mi?.mediaMetadata?.artist?.toString()
|
||||
?: mi?.mediaMetadata?.extras?.getString("artist")
|
||||
val album = mi?.mediaMetadata?.albumTitle?.toString()
|
||||
?: mi?.mediaMetadata?.extras?.getString("album")
|
||||
val extras = mi?.mediaMetadata?.extras
|
||||
val coverId = extras?.getString("coverArtId")
|
||||
val songLink = extras?.getString("assetLinkSong")
|
||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id"))
|
||||
val albumLink = extras?.getString("assetLinkAlbum")
|
||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId"))
|
||||
val artistLink = extras?.getString("assetLinkArtist")
|
||||
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId"))
|
||||
val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
|
||||
val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
|
||||
WidgetUpdateManager.updateFromState(
|
||||
this,
|
||||
title ?: "",
|
||||
artist ?: "",
|
||||
album ?: "",
|
||||
coverId,
|
||||
player.isPlaying,
|
||||
player.shuffleModeEnabled,
|
||||
player.repeatMode,
|
||||
position,
|
||||
duration,
|
||||
songLink,
|
||||
albumLink,
|
||||
artistLink
|
||||
)
|
||||
}
|
||||
|
||||
private fun scheduleWidgetUpdates() {
|
||||
if (widgetUpdateScheduled) return
|
||||
widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS)
|
||||
widgetUpdateScheduled = true
|
||||
}
|
||||
|
||||
private fun stopWidgetUpdates() {
|
||||
if (!widgetUpdateScheduled) return
|
||||
widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable)
|
||||
widgetUpdateScheduled = false
|
||||
}
|
||||
|
||||
private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean {
|
||||
if (audioSessionId == 0 || audioSessionId == -1) return false
|
||||
val attached = equalizerManager.attachToSession(audioSessionId)
|
||||
if (attached) {
|
||||
val enabled = Preferences.isEqualizerEnabled()
|
||||
equalizerManager.setEnabled(enabled)
|
||||
val bands = equalizerManager.getNumberOfBands()
|
||||
val savedLevels = Preferences.getEqualizerBandLevels(bands)
|
||||
for (i in 0 until bands) {
|
||||
equalizerManager.setBandLevel(i.toShort(), savedLevels[i])
|
||||
}
|
||||
sendBroadcast(Intent(ACTION_EQUALIZER_UPDATED))
|
||||
}
|
||||
return attached
|
||||
}
|
||||
|
||||
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
|
||||
|
||||
private fun getMediaSourceFactory(): MediaSource.Factory = DynamicMediaSourceFactory(this)
|
||||
|
||||
@UnstableApi
|
||||
private class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback {
|
||||
private val shuffleCommands: List<CommandButton>
|
||||
private val repeatCommands: List<CommandButton>
|
||||
|
||||
constructor(ctx: Context) {
|
||||
shuffleCommands = listOf(
|
||||
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON,
|
||||
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF
|
||||
)
|
||||
.map { getShuffleCommandButton(SessionCommand(it, Bundle.EMPTY), ctx) }
|
||||
repeatCommands = listOf(
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE,
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL
|
||||
)
|
||||
.map { getRepeatCommandButton(SessionCommand(it, Bundle.EMPTY), ctx) }
|
||||
}
|
||||
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: ControllerInfo
|
||||
): MediaSession.ConnectionResult {
|
||||
val connectionResult = super.onConnect(session, controller)
|
||||
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
|
||||
|
||||
(shuffleCommands + repeatCommands).forEach { commandButton ->
|
||||
commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
|
||||
}
|
||||
|
||||
val result = MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(availableSessionCommands.build())
|
||||
.setAvailablePlayerCommands(connectionResult.availablePlayerCommands)
|
||||
.setMediaButtonPreferences(buildCustomLayout(session.player))
|
||||
.build()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle
|
||||
): ListenableFuture<SessionResult> {
|
||||
Log.d(javaClass.toString(), "onCustomCommand")
|
||||
when (customCommand.customAction) {
|
||||
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> session.player.shuffleModeEnabled = true
|
||||
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> session.player.shuffleModeEnabled = false
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL,
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> {
|
||||
val nextMode = when (session.player.repeatMode) {
|
||||
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL
|
||||
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE
|
||||
else -> Player.REPEAT_MODE_OFF
|
||||
}
|
||||
session.player.repeatMode = nextMode
|
||||
}
|
||||
}
|
||||
|
||||
session.setMediaButtonPreferences(buildCustomLayout(session.player))
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: ControllerInfo,
|
||||
mediaItems: List<MediaItem>
|
||||
): ListenableFuture<List<MediaItem>> {
|
||||
Log.d(javaClass.toString(), "onAddMediaItems")
|
||||
val updatedMediaItems = mediaItems.map { mediaItem ->
|
||||
val mediaMetadata = mediaItem.mediaMetadata
|
||||
val newMetadata = mediaMetadata.buildUpon()
|
||||
.setArtist(
|
||||
if (mediaMetadata.artist != null) mediaMetadata.artist
|
||||
else mediaMetadata.extras?.getString("uri") ?: ""
|
||||
)
|
||||
.build()
|
||||
|
||||
mediaItem.buildUpon()
|
||||
.setUri(mediaItem.requestMetadata.mediaUri)
|
||||
.setMediaMetadata(newMetadata)
|
||||
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)
|
||||
.build()
|
||||
}
|
||||
return Futures.immediateFuture(updatedMediaItems)
|
||||
}
|
||||
|
||||
@SuppressLint("PrivateResource")
|
||||
private fun getShuffleCommandButton(
|
||||
sessionCommand: SessionCommand,
|
||||
ctx: Context
|
||||
): CommandButton {
|
||||
val isOn = sessionCommand.customAction == CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON
|
||||
return CommandButton.Builder(if (isOn) CommandButton.ICON_SHUFFLE_OFF else CommandButton.ICON_SHUFFLE_ON)
|
||||
.setSessionCommand(sessionCommand)
|
||||
.setDisplayName(
|
||||
ctx.getString(
|
||||
if (isOn) R.string.exo_controls_shuffle_on_description
|
||||
else R.string.exo_controls_shuffle_off_description
|
||||
)
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
@SuppressLint("PrivateResource")
|
||||
private fun getRepeatCommandButton(
|
||||
sessionCommand: SessionCommand,
|
||||
ctx: Context
|
||||
): CommandButton {
|
||||
val icon = when (sessionCommand.customAction) {
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> CommandButton.ICON_REPEAT_ONE
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> CommandButton.ICON_REPEAT_ALL
|
||||
else -> CommandButton.ICON_REPEAT_OFF
|
||||
}
|
||||
val description = when (sessionCommand.customAction) {
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> R.string.exo_controls_repeat_one_description
|
||||
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> R.string.exo_controls_repeat_all_description
|
||||
else -> R.string.exo_controls_repeat_off_description
|
||||
}
|
||||
return CommandButton.Builder(icon)
|
||||
.setSessionCommand(sessionCommand)
|
||||
.setDisplayName(ctx.getString(description))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildCustomLayout(player: Player): ImmutableList<CommandButton> {
|
||||
val shuffle = shuffleCommands[if (player.shuffleModeEnabled) 1 else 0]
|
||||
val repeat = when (player.repeatMode) {
|
||||
Player.REPEAT_MODE_ONE -> repeatCommands[1]
|
||||
Player.REPEAT_MODE_ALL -> repeatCommands[2]
|
||||
else -> repeatCommands[0]
|
||||
}
|
||||
return ImmutableList.of(shuffle, repeat)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CustomNetworkCallback : ConnectivityManager.NetworkCallback() {
|
||||
var wasWifi = false
|
||||
|
||||
init {
|
||||
val manager = getSystemService(ConnectivityManager::class.java)
|
||||
val network = manager.activeNetwork
|
||||
val capabilities = manager.getNetworkCapabilities(network)
|
||||
if (capabilities != null)
|
||||
wasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
if (isWifi != wasWifi) {
|
||||
wasWifi = isWifi
|
||||
widgetUpdateHandler.post {
|
||||
updateMediaItems(mediaLibrarySession.player)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
fun getEqualizerManager(): EqualizerManager {
|
||||
return equalizerManager
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package com.cappielloantonio.tempo.service;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.OptIn;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.media3.common.MediaItem;
|
||||
@@ -36,10 +37,16 @@ import com.google.common.util.concurrent.MoreExecutors;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class MediaManager {
|
||||
private static final String TAG = "MediaManager";
|
||||
private static WeakReference<MediaBrowser> attachedBrowserRef = new WeakReference<>(null);
|
||||
public static AtomicBoolean justStarted = new AtomicBoolean(false);
|
||||
|
||||
private static final ExecutorService backgroundExecutor = Executors.newSingleThreadExecutor();
|
||||
|
||||
public static void registerPlaybackObserver(
|
||||
ListenableFuture<MediaBrowser> browserFuture,
|
||||
@@ -173,36 +180,44 @@ public class MediaManager {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public static void startQueue(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, List<Child> media, int startIndex) {
|
||||
if (mediaBrowserListenableFuture != null) {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
browser.clearMediaItems();
|
||||
browser.setMediaItems(MappingUtil.mapMediaItems(media));
|
||||
browser.prepare();
|
||||
final MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
final List<MediaItem> items = MappingUtil.mapMediaItems(media);
|
||||
new Handler(Looper.getMainLooper()).post(() -> {
|
||||
justStarted.set(true);
|
||||
browser.setMediaItems(items, startIndex, 0);
|
||||
browser.prepare();
|
||||
|
||||
Player.Listener timelineListener = new Player.Listener() {
|
||||
@Override
|
||||
public void onTimelineChanged(Timeline timeline, int reason) {
|
||||
int itemCount = browser.getMediaItemCount();
|
||||
if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) {
|
||||
browser.seekTo(startIndex, 0);
|
||||
browser.play();
|
||||
browser.removeListener(this);
|
||||
Player.Listener timelineListener = new Player.Listener() {
|
||||
@Override
|
||||
public void onTimelineChanged(Timeline timeline, int reason) {
|
||||
int itemCount = browser.getMediaItemCount();
|
||||
if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) {
|
||||
browser.seekTo(startIndex, 0);
|
||||
browser.play();
|
||||
browser.removeListener(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
browser.addListener(timelineListener);
|
||||
};
|
||||
browser.addListener(timelineListener);
|
||||
});
|
||||
|
||||
enqueueDatabase(media, true, 0);
|
||||
backgroundExecutor.execute(() -> {
|
||||
enqueueDatabase(media, true, 0);
|
||||
});
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
Log.e(TAG, "Error executing startQueue logic: " + e.getMessage(), e);
|
||||
}
|
||||
}, MoreExecutors.directExecutor());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
public static void startQueue(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, Child media) {
|
||||
@@ -210,10 +225,11 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
mediaBrowserListenableFuture.get().clearMediaItems();
|
||||
mediaBrowserListenableFuture.get().setMediaItem(MappingUtil.mapMediaItem(media));
|
||||
mediaBrowserListenableFuture.get().prepare();
|
||||
mediaBrowserListenableFuture.get().play();
|
||||
MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
justStarted.set(true);
|
||||
browser.setMediaItem(MappingUtil.mapMediaItem(media));
|
||||
browser.prepare();
|
||||
browser.play();
|
||||
enqueueDatabase(media, true, 0);
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
@@ -229,7 +245,7 @@ public class MediaManager {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
|
||||
mediaBrowser.clearMediaItems();
|
||||
justStarted.set(true);
|
||||
mediaBrowser.setMediaItem(mediaItem);
|
||||
mediaBrowser.prepare();
|
||||
mediaBrowser.play();
|
||||
@@ -247,10 +263,11 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
mediaBrowserListenableFuture.get().clearMediaItems();
|
||||
mediaBrowserListenableFuture.get().setMediaItem(MappingUtil.mapInternetRadioStation(internetRadioStation));
|
||||
mediaBrowserListenableFuture.get().prepare();
|
||||
mediaBrowserListenableFuture.get().play();
|
||||
MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
justStarted.set(true);
|
||||
browser.setMediaItem(MappingUtil.mapInternetRadioStation(internetRadioStation));
|
||||
browser.prepare();
|
||||
browser.play();
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
@@ -264,10 +281,11 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
mediaBrowserListenableFuture.get().clearMediaItems();
|
||||
mediaBrowserListenableFuture.get().setMediaItem(MappingUtil.mapMediaItem(podcastEpisode));
|
||||
mediaBrowserListenableFuture.get().prepare();
|
||||
mediaBrowserListenableFuture.get().play();
|
||||
MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
justStarted.set(true);
|
||||
browser.setMediaItem(MappingUtil.mapMediaItem(podcastEpisode));
|
||||
browser.prepare();
|
||||
browser.play();
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
@@ -281,9 +299,11 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
if (playImmediatelyAfter && mediaBrowserListenableFuture.get().getNextMediaItemIndex() != -1) {
|
||||
enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getNextMediaItemIndex());
|
||||
mediaBrowserListenableFuture.get().addMediaItems(mediaBrowserListenableFuture.get().getNextMediaItemIndex(), MappingUtil.mapMediaItems(media));
|
||||
Log.e(TAG, "enqueue");
|
||||
MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
if (playImmediatelyAfter && browser.getNextMediaItemIndex() != -1) {
|
||||
enqueueDatabase(media, false, browser.getNextMediaItemIndex());
|
||||
browser.addMediaItems(browser.getNextMediaItemIndex(), MappingUtil.mapMediaItems(media));
|
||||
} else {
|
||||
enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getMediaItemCount());
|
||||
mediaBrowserListenableFuture.get().addMediaItems(MappingUtil.mapMediaItems(media));
|
||||
@@ -301,9 +321,11 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
if (playImmediatelyAfter && mediaBrowserListenableFuture.get().getNextMediaItemIndex() != -1) {
|
||||
enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getNextMediaItemIndex());
|
||||
mediaBrowserListenableFuture.get().addMediaItem(mediaBrowserListenableFuture.get().getNextMediaItemIndex(), MappingUtil.mapMediaItem(media));
|
||||
Log.e(TAG, "enqueue");
|
||||
MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
if (playImmediatelyAfter && browser.getNextMediaItemIndex() != -1) {
|
||||
enqueueDatabase(media, false, browser.getNextMediaItemIndex());
|
||||
browser.addMediaItem(browser.getNextMediaItemIndex(), MappingUtil.mapMediaItem(media));
|
||||
} else {
|
||||
enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getMediaItemCount());
|
||||
mediaBrowserListenableFuture.get().addMediaItem(MappingUtil.mapMediaItem(media));
|
||||
@@ -321,8 +343,10 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
mediaBrowserListenableFuture.get().removeMediaItems(startIndex, endIndex + 1);
|
||||
mediaBrowserListenableFuture.get().addMediaItems(MappingUtil.mapMediaItems(media).subList(startIndex, endIndex + 1));
|
||||
Log.e(TAG, "shuffle");
|
||||
MediaBrowser browser = mediaBrowserListenableFuture.get();
|
||||
browser.removeMediaItems(startIndex, endIndex + 1);
|
||||
browser.addMediaItems(MappingUtil.mapMediaItems(media).subList(startIndex, endIndex + 1));
|
||||
swapDatabase(media);
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
@@ -337,6 +361,7 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
Log.e(TAG, "swap");
|
||||
mediaBrowserListenableFuture.get().moveMediaItem(from, to);
|
||||
swapDatabase(media);
|
||||
}
|
||||
@@ -352,6 +377,7 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
Log.e(TAG, "remove");
|
||||
if (mediaBrowserListenableFuture.get().getMediaItemCount() > 1 && mediaBrowserListenableFuture.get().getCurrentMediaItemIndex() != toRemove) {
|
||||
mediaBrowserListenableFuture.get().removeMediaItem(toRemove);
|
||||
removeDatabase(media, toRemove);
|
||||
@@ -371,6 +397,7 @@ public class MediaManager {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
if (mediaBrowserListenableFuture.isDone()) {
|
||||
Log.e(TAG, "remove range");
|
||||
mediaBrowserListenableFuture.get().removeMediaItems(fromItem, toItem);
|
||||
removeRangeDatabase(media, fromItem, toItem);
|
||||
}
|
||||
@@ -420,6 +447,7 @@ public class MediaManager {
|
||||
@Override
|
||||
public void onChanged(List<Child> media) {
|
||||
if (media != null) {
|
||||
Log.e(TAG, "continuous play");
|
||||
ListenableFuture<MediaBrowser> mediaBrowserListenableFuture = new MediaBrowser.Builder(
|
||||
App.getContext(),
|
||||
new SessionToken(App.getContext(), new ComponentName(App.getContext(), MediaService.class))
|
||||
|
||||
@@ -2,22 +2,28 @@ package com.cappielloantonio.tempo.subsonic
|
||||
|
||||
import com.cappielloantonio.tempo.App
|
||||
import com.cappielloantonio.tempo.subsonic.utils.CacheUtil
|
||||
import com.cappielloantonio.tempo.subsonic.utils.EmptyDateTypeAdapter
|
||||
import com.google.gson.GsonBuilder
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class RetrofitClient(subsonic: Subsonic) {
|
||||
var retrofit: Retrofit
|
||||
|
||||
init {
|
||||
val gson = GsonBuilder()
|
||||
.registerTypeAdapter(Date::class.java, EmptyDateTypeAdapter())
|
||||
.setLenient()
|
||||
.create()
|
||||
|
||||
retrofit = Retrofit.Builder()
|
||||
.baseUrl(subsonic.url)
|
||||
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create()))
|
||||
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().setLenient().create()))
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.client(getOkHttpClient())
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import java.util.UUID;
|
||||
public class SubsonicPreferences {
|
||||
private String serverUrl;
|
||||
private String username;
|
||||
private String clientName = "Tempo";
|
||||
private String clientName = "Tempus";
|
||||
private SubsonicAuthentication authentication;
|
||||
|
||||
public String getServerUrl() {
|
||||
|
||||
@@ -34,6 +34,11 @@ public class AlbumSongListClient {
|
||||
return albumSongListService.getRandomSongs(subsonic.getParams(), size, fromYear, toYear);
|
||||
}
|
||||
|
||||
public Call<ApiResponse> getRandomSongs(int size, Integer fromYear, Integer toYear, String genre) {
|
||||
Log.d(TAG, "getRandomSongs()");
|
||||
return albumSongListService.getRandomSongs(subsonic.getParams(), size, fromYear, toYear, genre);
|
||||
}
|
||||
|
||||
public Call<ApiResponse> getSongsByGenre(String genre, int count, int offset) {
|
||||
Log.d(TAG, "getSongsByGenre()");
|
||||
return albumSongListService.getSongsByGenre(subsonic.getParams(), genre, count, offset);
|
||||
|
||||
@@ -19,6 +19,9 @@ public interface AlbumSongListService {
|
||||
@GET("getRandomSongs")
|
||||
Call<ApiResponse> getRandomSongs(@QueryMap Map<String, String> params, @Query("size") int size, @Query("fromYear") Integer fromYear, @Query("toYear") Integer toYear);
|
||||
|
||||
@GET("getRandomSongs")
|
||||
Call<ApiResponse> getRandomSongs(@QueryMap Map<String, String> params, @Query("size") int size, @Query("fromYear") Integer fromYear, @Query("toYear") Integer toYear, @Query("genre") String genre);
|
||||
|
||||
@GET("getSongsByGenre")
|
||||
Call<ApiResponse> getSongsByGenre(@QueryMap Map<String, String> params, @Query("genre") String genre, @Query("count") int count, @Query("offset") int offset);
|
||||
|
||||
|
||||
@@ -4,38 +4,36 @@ import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class AlbumID3 : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
var artist: String? = null
|
||||
var artistId: String? = null
|
||||
open class AlbumID3(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
var artist: String? = null,
|
||||
var artistId: String? = null,
|
||||
@SerializedName("coverArt")
|
||||
var coverArtId: String? = null
|
||||
var songCount: Int? = 0
|
||||
var duration: Int? = 0
|
||||
var playCount: Long? = 0
|
||||
var created: Date? = null
|
||||
var starred: Date? = null
|
||||
var year: Int = 0
|
||||
var genre: String? = null
|
||||
var played: Date? = Date(0)
|
||||
var userRating: Int? = 0
|
||||
var recordLabels: List<RecordLabel>? = null
|
||||
var musicBrainzId: String? = null
|
||||
var genres: List<ItemGenre>? = null
|
||||
var artists: List<ArtistID3>? = null
|
||||
var displayArtist: String? = null
|
||||
var releaseTypes: List<String>? = null
|
||||
var moods: List<String>? = null
|
||||
var sortName: String? = null
|
||||
var originalReleaseDate: ItemDate? = null
|
||||
var releaseDate: ItemDate? = null
|
||||
var isCompilation: Boolean? = null
|
||||
var discTitles: List<DiscTitle>? = null
|
||||
}
|
||||
var coverArtId: String? = null,
|
||||
var songCount: Int? = 0,
|
||||
var duration: Int? = 0,
|
||||
var playCount: Long? = 0,
|
||||
var created: Date? = null,
|
||||
var starred: Date? = null,
|
||||
var year: Int = 0,
|
||||
var genre: String? = null,
|
||||
var played: Date? = Date(0),
|
||||
var userRating: Int? = 0,
|
||||
var recordLabels: List<RecordLabel>? = null,
|
||||
var musicBrainzId: String? = null,
|
||||
var genres: List<ItemGenre>? = null,
|
||||
var artists: List<ArtistID3>? = null,
|
||||
var displayArtist: String? = null,
|
||||
var releaseTypes: List<String>? = null,
|
||||
var moods: List<String>? = null,
|
||||
var sortName: String? = null,
|
||||
var originalReleaseDate: ItemDate? = null,
|
||||
var releaseDate: ItemDate? = null,
|
||||
var isCompilation: Boolean? = null,
|
||||
var discTitles: List<DiscTitle>? = null,
|
||||
) : Parcelable
|
||||
@@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class AlbumWithSongsID3 : AlbumID3(), Parcelable {
|
||||
class AlbumWithSongsID3(
|
||||
@SerializedName("song")
|
||||
var songs: List<Child>? = null
|
||||
}
|
||||
var songs: List<Child>? = null,
|
||||
) : AlbumID3(), Parcelable
|
||||
@@ -7,10 +7,10 @@ import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class Artist : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
var starred: Date? = null
|
||||
var userRating: Int? = null
|
||||
var averageRating: Double? = null
|
||||
}
|
||||
class Artist(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
var starred: Date? = null,
|
||||
var userRating: Int? = null,
|
||||
var averageRating: Double? = null,
|
||||
) : Parcelable
|
||||
@@ -4,15 +4,15 @@ import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class ArtistID3 : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
open class ArtistID3(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
@SerializedName("coverArt")
|
||||
var coverArtId: String? = null
|
||||
var albumCount = 0
|
||||
var starred: Date? = null
|
||||
}
|
||||
var coverArtId: String? = null,
|
||||
var albumCount: Int = 0,
|
||||
var starred: Date? = null,
|
||||
) : Parcelable
|
||||
@@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class ArtistWithAlbumsID3 : ArtistID3(), Parcelable {
|
||||
class ArtistWithAlbumsID3(
|
||||
@SerializedName("album")
|
||||
var albums: List<AlbumID3>? = null
|
||||
}
|
||||
var albums: List<AlbumID3>? = null,
|
||||
) : ArtistID3(), Parcelable
|
||||
@@ -8,15 +8,15 @@ import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class Directory : Parcelable {
|
||||
class Directory(
|
||||
@SerializedName("child")
|
||||
var children: List<Child>? = null
|
||||
var id: String? = null
|
||||
var children: List<Child>? = null,
|
||||
var id: String? = null,
|
||||
@SerializedName("parent")
|
||||
var parentId: String? = null
|
||||
var name: String? = null
|
||||
var starred: Date? = null
|
||||
var userRating: Int? = null
|
||||
var averageRating: Double? = null
|
||||
var playCount: Long? = null
|
||||
}
|
||||
var parentId: String? = null,
|
||||
var name: String? = null,
|
||||
var starred: Date? = null,
|
||||
var userRating: Int? = null,
|
||||
var averageRating: Double? = null,
|
||||
var playCount: Long? = null,
|
||||
) : Parcelable
|
||||
@@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class DiscTitle : Parcelable {
|
||||
var disc: Int? = null
|
||||
var title: String? = null
|
||||
}
|
||||
open class DiscTitle(
|
||||
var disc: Int? = null,
|
||||
var title: String? = null,
|
||||
) : Parcelable
|
||||
@@ -7,9 +7,9 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class Genre : Parcelable {
|
||||
class Genre(
|
||||
@SerializedName("value")
|
||||
var genre: String? = null
|
||||
var songCount = 0
|
||||
var albumCount = 0
|
||||
}
|
||||
var genre: String? = null,
|
||||
var songCount: Int = 0,
|
||||
var albumCount: Int = 0,
|
||||
) : Parcelable
|
||||
@@ -6,9 +6,9 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class InternetRadioStation : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
var streamUrl: String? = null
|
||||
var homePageUrl: String? = null
|
||||
}
|
||||
class InternetRadioStation(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
var streamUrl: String? = null,
|
||||
var homePageUrl: String? = null,
|
||||
) : Parcelable
|
||||
@@ -9,11 +9,11 @@ import java.util.Locale
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class ItemDate : Parcelable {
|
||||
var year: Int? = null
|
||||
var month: Int? = null
|
||||
var day: Int? = null
|
||||
|
||||
open class ItemDate(
|
||||
var year: Int? = null,
|
||||
var month: Int? = null,
|
||||
var day: Int? = null,
|
||||
) : Parcelable {
|
||||
fun getFormattedDate(): String? {
|
||||
if (year == null && month == null && day == null) return null
|
||||
|
||||
@@ -22,8 +22,7 @@ open class ItemDate : Parcelable {
|
||||
SimpleDateFormat("yyyy", Locale.getDefault())
|
||||
} else if (day == null) {
|
||||
SimpleDateFormat("MMMM yyyy", Locale.getDefault())
|
||||
}
|
||||
else{
|
||||
} else {
|
||||
SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault())
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,6 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class ItemGenre : Parcelable {
|
||||
var name: String? = null
|
||||
}
|
||||
open class ItemGenre(
|
||||
var name: String? = null,
|
||||
) : Parcelable
|
||||
@@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class MusicFolder : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
}
|
||||
class MusicFolder(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
) : Parcelable
|
||||
@@ -8,10 +8,9 @@ import kotlinx.parcelize.Parcelize
|
||||
@Parcelize
|
||||
class NowPlayingEntry(
|
||||
@SerializedName("_id")
|
||||
override val id: String
|
||||
) : Child(id) {
|
||||
var username: String? = null
|
||||
var minutesAgo = 0
|
||||
var playerId = 0
|
||||
var playerName: String? = null
|
||||
}
|
||||
override val id: String,
|
||||
var username: String? = null,
|
||||
var minutesAgo: Int = 0,
|
||||
var playerId: Int = 0,
|
||||
var playerName: String? = null,
|
||||
) : Child(id)
|
||||
@@ -7,8 +7,9 @@ import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.PrimaryKey
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
@@ -16,27 +17,56 @@ import java.util.*
|
||||
open class Playlist(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "id")
|
||||
open var id: String
|
||||
) : Parcelable {
|
||||
open var id: String,
|
||||
@ColumnInfo(name = "name")
|
||||
var name: String? = null
|
||||
var name: String? = null,
|
||||
@ColumnInfo(name = "duration")
|
||||
var duration: Long = 0,
|
||||
@ColumnInfo(name = "coverArt")
|
||||
var coverArtId: String? = null,
|
||||
) : Parcelable {
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var comment: String? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var owner: String? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
@SerializedName("public")
|
||||
var isUniversal: Boolean? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var songCount: Int = 0
|
||||
@ColumnInfo(name = "duration")
|
||||
var duration: Long = 0
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var created: Date? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var changed: Date? = null
|
||||
@ColumnInfo(name = "coverArt")
|
||||
var coverArtId: String? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var allowedUsers: List<String>? = null
|
||||
@Ignore
|
||||
constructor(
|
||||
id: String,
|
||||
name: String?,
|
||||
comment: String?,
|
||||
owner: String?,
|
||||
isUniversal: Boolean?,
|
||||
songCount: Int,
|
||||
duration: Long,
|
||||
created: Date?,
|
||||
changed: Date?,
|
||||
coverArtId: String?,
|
||||
allowedUsers: List<String>?,
|
||||
) : this(id, name, duration, coverArtId) {
|
||||
this.comment = comment
|
||||
this.owner = owner
|
||||
this.isUniversal = isUniversal
|
||||
this.songCount = songCount
|
||||
this.created = created
|
||||
this.changed = changed
|
||||
this.allowedUsers = allowedUsers
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@ import kotlinx.parcelize.Parcelize
|
||||
@Parcelize
|
||||
class PlaylistWithSongs(
|
||||
@SerializedName("_id")
|
||||
override var id: String
|
||||
) : Playlist(id), Parcelable {
|
||||
override var id: String,
|
||||
@SerializedName("entry")
|
||||
var entries: List<Child>? = null
|
||||
}
|
||||
var entries: List<Child>? = null,
|
||||
) : Playlist(id), Parcelable
|
||||
@@ -6,5 +6,5 @@ import com.google.gson.annotations.SerializedName
|
||||
@Keep
|
||||
class Playlists(
|
||||
@SerializedName("playlist")
|
||||
var playlists: List<Playlist>? = null
|
||||
var playlists: List<Playlist>? = null,
|
||||
)
|
||||
@@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class PodcastChannel : Parcelable {
|
||||
class PodcastChannel(
|
||||
@SerializedName("episode")
|
||||
var episodes: List<PodcastEpisode>? = null
|
||||
var id: String? = null
|
||||
var url: String? = null
|
||||
var title: String? = null
|
||||
var description: String? = null
|
||||
var episodes: List<PodcastEpisode>? = null,
|
||||
var id: String? = null,
|
||||
var url: String? = null,
|
||||
var title: String? = null,
|
||||
var description: String? = null,
|
||||
@SerializedName("coverArt")
|
||||
var coverArtId: String? = null
|
||||
var originalImageUrl: String? = null
|
||||
var status: String? = null
|
||||
var errorMessage: String? = null
|
||||
}
|
||||
var coverArtId: String? = null,
|
||||
var originalImageUrl: String? = null,
|
||||
var status: String? = null,
|
||||
var errorMessage: String? = null,
|
||||
) : Parcelable
|
||||
@@ -3,37 +3,38 @@ package com.cappielloantonio.tempo.subsonic.models
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class PodcastEpisode : Parcelable {
|
||||
var id: String? = null
|
||||
class PodcastEpisode(
|
||||
var id: String? = null,
|
||||
@SerializedName("parent")
|
||||
var parentId: String? = null
|
||||
var isDir = false
|
||||
var title: String? = null
|
||||
var album: String? = null
|
||||
var artist: String? = null
|
||||
var year: Int? = null
|
||||
var genre: String? = null
|
||||
var parentId: String? = null,
|
||||
var isDir: Boolean = false,
|
||||
var title: String? = null,
|
||||
var album: String? = null,
|
||||
var artist: String? = null,
|
||||
var year: Int? = null,
|
||||
var genre: String? = null,
|
||||
@SerializedName("coverArt")
|
||||
var coverArtId: String? = null
|
||||
var size: Long? = null
|
||||
var contentType: String? = null
|
||||
var suffix: String? = null
|
||||
var duration: Int? = null
|
||||
var coverArtId: String? = null,
|
||||
var size: Long? = null,
|
||||
var contentType: String? = null,
|
||||
var suffix: String? = null,
|
||||
var duration: Int? = null,
|
||||
@SerializedName("bitRate")
|
||||
var bitrate: Int? = null
|
||||
var path: String? = null
|
||||
var isVideo: Boolean = false
|
||||
var created: Date? = null
|
||||
var artistId: String? = null
|
||||
var type: String? = null
|
||||
var streamId: String? = null
|
||||
var channelId: String? = null
|
||||
var description: String? = null
|
||||
var status: String? = null
|
||||
var publishDate: Date? = null
|
||||
}
|
||||
var bitrate: Int? = null,
|
||||
var path: String? = null,
|
||||
var isVideo: Boolean = false,
|
||||
var created: Date? = null,
|
||||
var artistId: String? = null,
|
||||
var type: String? = null,
|
||||
var streamId: String? = null,
|
||||
var channelId: String? = null,
|
||||
var description: String? = null,
|
||||
var status: String? = null,
|
||||
var publishDate: Date? = null,
|
||||
) : Parcelable
|
||||
@@ -2,10 +2,11 @@ package com.cappielloantonio.tempo.subsonic.models
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class RecordLabel : Parcelable {
|
||||
var name: String? = null
|
||||
}
|
||||
open class RecordLabel(
|
||||
var name: String? = null,
|
||||
) : Parcelable
|
||||
@@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class Share : Parcelable {
|
||||
data class Share(
|
||||
@SerializedName("entry")
|
||||
var entries: List<Child>? = null
|
||||
var id: String? = null
|
||||
var url: String? = null
|
||||
var description: String? = null
|
||||
var username: String? = null
|
||||
var created: Date? = null
|
||||
var expires: Date? = null
|
||||
var lastVisited: Date? = null
|
||||
var visitCount = 0
|
||||
}
|
||||
var entries: List<Child>? = null,
|
||||
var id: String? = null,
|
||||
var url: String? = null,
|
||||
var description: String? = null,
|
||||
var username: String? = null,
|
||||
var created: Date? = null,
|
||||
var expires: Date? = null,
|
||||
var lastVisited: Date? = null,
|
||||
var visitCount: Int = 0
|
||||
) : Parcelable
|
||||
@@ -38,21 +38,36 @@ public class CacheUtil {
|
||||
return chain.proceed(request);
|
||||
};
|
||||
|
||||
|
||||
private boolean isConnected() {
|
||||
ConnectivityManager connectivityManager = (ConnectivityManager) App.getContext().getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
|
||||
if (connectivityManager != null) {
|
||||
Network network = connectivityManager.getActiveNetwork();
|
||||
|
||||
if (network != null) {
|
||||
NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network);
|
||||
|
||||
if (capabilities != null) {
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
|
||||
}
|
||||
}
|
||||
if (connectivityManager == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
Network network = connectivityManager.getActiveNetwork();
|
||||
if (network == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network);
|
||||
if (capabilities == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
|
||||
if (!hasInternet) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean hasAppropriateTransport = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET);
|
||||
if (!hasAppropriateTransport) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.cappielloantonio.tempo.subsonic.utils
|
||||
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParseException
|
||||
import java.lang.reflect.Type
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
// This adapter handles Date objects, returning null if the JSON string is empty or unparsable.
|
||||
class EmptyDateTypeAdapter : JsonDeserializer<Date> {
|
||||
|
||||
// Define the date formats expected from the Subsonic server.
|
||||
private val dateFormats: List<SimpleDateFormat> = listOf(
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") },
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") },
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
)
|
||||
|
||||
@Throws(JsonParseException::class)
|
||||
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Date? {
|
||||
val jsonString = json.asString.trim()
|
||||
|
||||
if (jsonString.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (format in dateFormats) {
|
||||
try {
|
||||
return format.parse(jsonString)
|
||||
} catch (e: ParseException) {
|
||||
// Ignore and try the next format
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import com.cappielloantonio.tempo.ui.dialog.ConnectionAlertDialog;
|
||||
import com.cappielloantonio.tempo.ui.dialog.GithubTempoUpdateDialog;
|
||||
import com.cappielloantonio.tempo.ui.dialog.ServerUnreachableDialog;
|
||||
import com.cappielloantonio.tempo.ui.fragment.PlayerBottomSheetFragment;
|
||||
import com.cappielloantonio.tempo.util.AssetLinkNavigator;
|
||||
import com.cappielloantonio.tempo.util.AssetLinkUtil;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.MainViewModel;
|
||||
@@ -60,6 +62,8 @@ public class MainActivity extends BaseActivity {
|
||||
private BottomNavigationView bottomNavigationView;
|
||||
public NavController navController;
|
||||
private BottomSheetBehavior bottomSheetBehavior;
|
||||
private AssetLinkNavigator assetLinkNavigator;
|
||||
private AssetLinkUtil.AssetLink pendingAssetLink;
|
||||
|
||||
ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver;
|
||||
private Intent pendingDownloadPlaybackIntent;
|
||||
@@ -76,6 +80,7 @@ public class MainActivity extends BaseActivity {
|
||||
setContentView(view);
|
||||
|
||||
mainViewModel = new ViewModelProvider(this).get(MainViewModel.class);
|
||||
assetLinkNavigator = new AssetLinkNavigator(this);
|
||||
|
||||
connectivityStatusBroadcastReceiver = new ConnectivityStatusBroadcastReceiver(this);
|
||||
connectivityStatusReceiverManager(true);
|
||||
@@ -311,6 +316,24 @@ public class MainActivity extends BaseActivity {
|
||||
public void goFromLogin() {
|
||||
setBottomSheetInPeek(mainViewModel.isQueueLoaded());
|
||||
goToHome();
|
||||
consumePendingAssetLink();
|
||||
}
|
||||
|
||||
public void openAssetLink(@NonNull AssetLinkUtil.AssetLink assetLink) {
|
||||
openAssetLink(assetLink, true);
|
||||
}
|
||||
|
||||
public void openAssetLink(@NonNull AssetLinkUtil.AssetLink assetLink, boolean collapsePlayer) {
|
||||
if (!isUserAuthenticated()) {
|
||||
pendingAssetLink = assetLink;
|
||||
return;
|
||||
}
|
||||
if (collapsePlayer) {
|
||||
setBottomSheetInPeek(true);
|
||||
}
|
||||
if (assetLinkNavigator != null) {
|
||||
assetLinkNavigator.open(assetLink);
|
||||
}
|
||||
}
|
||||
|
||||
public void quit() {
|
||||
@@ -415,7 +438,7 @@ public class MainActivity extends BaseActivity {
|
||||
}
|
||||
|
||||
private void checkTempoUpdate() {
|
||||
if (BuildConfig.FLAVOR.equals("tempo") && Preferences.showTempoUpdateDialog()) {
|
||||
if (BuildConfig.FLAVOR.equals("tempus") && Preferences.isGithubUpdateEnabled() && Preferences.showTempusUpdateDialog()) {
|
||||
mainViewModel.checkTempoUpdate().observe(this, latestRelease -> {
|
||||
if (latestRelease != null && UpdateUtil.showUpdateDialog(latestRelease)) {
|
||||
GithubTempoUpdateDialog dialog = new GithubTempoUpdateDialog(latestRelease);
|
||||
@@ -443,6 +466,7 @@ public class MainActivity extends BaseActivity {
|
||||
|| intent.hasExtra(Constants.EXTRA_DOWNLOAD_URI)) {
|
||||
pendingDownloadPlaybackIntent = new Intent(intent);
|
||||
}
|
||||
handleAssetLinkIntent(intent);
|
||||
}
|
||||
|
||||
private void consumePendingPlaybackIntent() {
|
||||
@@ -452,6 +476,35 @@ public class MainActivity extends BaseActivity {
|
||||
playDownloadedMedia(intent);
|
||||
}
|
||||
|
||||
private void handleAssetLinkIntent(Intent intent) {
|
||||
AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.parse(intent);
|
||||
if (assetLink == null) {
|
||||
return;
|
||||
}
|
||||
if (!isUserAuthenticated()) {
|
||||
pendingAssetLink = assetLink;
|
||||
intent.setData(null);
|
||||
return;
|
||||
}
|
||||
if (assetLinkNavigator != null) {
|
||||
assetLinkNavigator.open(assetLink);
|
||||
}
|
||||
intent.setData(null);
|
||||
}
|
||||
|
||||
private boolean isUserAuthenticated() {
|
||||
return Preferences.getPassword() != null
|
||||
|| (Preferences.getToken() != null && Preferences.getSalt() != null);
|
||||
}
|
||||
|
||||
private void consumePendingAssetLink() {
|
||||
if (pendingAssetLink == null || assetLinkNavigator == null) {
|
||||
return;
|
||||
}
|
||||
assetLinkNavigator.open(pendingAssetLink);
|
||||
pendingAssetLink = null;
|
||||
}
|
||||
|
||||
private void playDownloadedMedia(Intent intent) {
|
||||
String uriString = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_URI);
|
||||
if (TextUtils.isEmpty(uriString)) {
|
||||
@@ -500,4 +553,4 @@ public class MainActivity extends BaseActivity {
|
||||
|
||||
MediaManager.playDownloadedMediaItem(getMediaBrowserListenableFuture(), mediaItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAdapter.ViewHolder> implements Filterable {
|
||||
@@ -151,13 +152,27 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAd
|
||||
}
|
||||
}
|
||||
|
||||
public void setItemsWithoutFilter(List<AlbumID3> albums) {
|
||||
this.albumsFull = new ArrayList<>(albums);
|
||||
this.albums = new ArrayList<>(albums);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void sort(String order) {
|
||||
if (albums == null) return;
|
||||
|
||||
switch (order) {
|
||||
case Constants.ALBUM_ORDER_BY_NAME:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getName));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getName() != null ? album.getName() : "",
|
||||
String.CASE_INSENSITIVE_ORDER
|
||||
));
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_ARTIST:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getArtist, Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getArtist() != null ? album.getArtist() : "",
|
||||
String.CASE_INSENSITIVE_ORDER
|
||||
));
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_YEAR:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getYear));
|
||||
@@ -166,15 +181,23 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAd
|
||||
Collections.shuffle(albums);
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_RECENTLY_ADDED:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getCreated));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getCreated() != null ? album.getCreated() : new Date(0),
|
||||
Comparator.nullsLast(Date::compareTo)
|
||||
));
|
||||
Collections.reverse(albums);
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getPlayed));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getPlayed() != null ? album.getPlayed() : new Date(0),
|
||||
Comparator.nullsLast(Date::compareTo)
|
||||
));
|
||||
Collections.reverse(albums);
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_MOST_PLAYED:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getPlayCount));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getPlayCount() != null ? album.getPlayCount() : 0L
|
||||
));
|
||||
Collections.reverse(albums);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -151,6 +151,9 @@ public class ArtistCatalogueAdapter extends RecyclerView.Adapter<ArtistCatalogue
|
||||
case Constants.ARTIST_ORDER_BY_RANDOM:
|
||||
Collections.shuffle(artists);
|
||||
break;
|
||||
case Constants.ARTIST_ORDER_BY_ALBUM_COUNT:
|
||||
artists.sort(Comparator.comparing(ArtistID3::getAlbumCount).reversed());
|
||||
break;
|
||||
}
|
||||
|
||||
notifyDataSetChanged();
|
||||
|
||||
@@ -73,6 +73,11 @@ public class DiscoverSongAdapter extends RecyclerView.Adapter<DiscoverSongAdapte
|
||||
this.item = item;
|
||||
|
||||
itemView.setOnClickListener(v -> onClick());
|
||||
|
||||
itemView.setOnLongClickListener(v -> {
|
||||
onLongClick();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public void onClick() {
|
||||
@@ -82,6 +87,13 @@ public class DiscoverSongAdapter extends RecyclerView.Adapter<DiscoverSongAdapte
|
||||
|
||||
click.onMediaClick(bundle);
|
||||
}
|
||||
|
||||
private boolean onLongClick() {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putParcelable(Constants.TRACK_OBJECT, songs.get(getBindingAdapterPosition()));
|
||||
click.onMediaLongClick(bundle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void startAnimation(ViewHolder holder) {
|
||||
|
||||
@@ -191,7 +191,7 @@ public class DownloadHorizontalAdapter extends RecyclerView.Adapter<DownloadHori
|
||||
R.string.song_subtitle_formatter,
|
||||
song.getArtist(),
|
||||
MusicUtil.getReadableDurationString(song.getDuration(), false),
|
||||
""
|
||||
MusicUtil.getReadableAudioQualityString(song)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ public class MusicDirectoryAdapter extends RecyclerView.Adapter<MusicDirectoryAd
|
||||
.into(holder.item.musicDirectoryCoverImageView);
|
||||
|
||||
holder.item.musicDirectoryMoreButton.setVisibility(child.isDir() ? View.VISIBLE : View.INVISIBLE);
|
||||
holder.item.musicDirectoryPlayButton.setVisibility(child.isDir() ? View.INVISIBLE : View.VISIBLE);
|
||||
holder.item.musicDirectoryPlayButton.setVisibility(child.isDir() ? View.VISIBLE : View.INVISIBLE);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -80,6 +80,7 @@ public class MusicDirectoryAdapter extends RecyclerView.Adapter<MusicDirectoryAd
|
||||
itemView.setOnLongClickListener(v -> onLongClick());
|
||||
|
||||
item.musicDirectoryMoreButton.setOnClickListener(v -> onClick());
|
||||
item.musicDirectoryPlayButton.setOnClickListener(v -> onPlayClick());
|
||||
}
|
||||
|
||||
public void onClick() {
|
||||
@@ -107,5 +108,13 @@ public class MusicDirectoryAdapter extends RecyclerView.Adapter<MusicDirectoryAd
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void onPlayClick() {
|
||||
if (children.get(getBindingAdapterPosition()).isDir()) {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putString(Constants.MUSIC_DIRECTORY_ID, children.get(getBindingAdapterPosition()).getId());
|
||||
click.onMusicDirectoryPlay(bundle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ public class MusicIndexAdapter extends RecyclerView.Adapter<MusicIndexAdapter.Vi
|
||||
|
||||
itemView.setOnClickListener(v -> onClick());
|
||||
item.musicIndexMoreButton.setOnClickListener(v -> onClick());
|
||||
item.musicIndexPlayButton.setOnClickListener(v -> onPlayClick());
|
||||
}
|
||||
|
||||
public void onClick() {
|
||||
@@ -83,5 +84,11 @@ public class MusicIndexAdapter extends RecyclerView.Adapter<MusicIndexAdapter.Vi
|
||||
bundle.putString(Constants.MUSIC_DIRECTORY_ID, artists.get(getBindingAdapterPosition()).getId());
|
||||
click.onMusicIndexClick(bundle);
|
||||
}
|
||||
|
||||
public void onPlayClick() {
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putString(Constants.MUSIC_DIRECTORY_ID, artists.get(getBindingAdapterPosition()).getId());
|
||||
click.onMusicIndexPlay(bundle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ import com.cappielloantonio.tempo.databinding.ItemPlayerQueueSongBinding;
|
||||
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
|
||||
import com.cappielloantonio.tempo.interfaces.ClickCallback;
|
||||
import com.cappielloantonio.tempo.interfaces.MediaIndexCallback;
|
||||
import com.cappielloantonio.tempo.service.DownloaderManager;
|
||||
import com.cappielloantonio.tempo.service.MediaManager;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
@@ -94,6 +96,20 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
|
||||
}
|
||||
});
|
||||
|
||||
DownloaderManager downloaderManager = DownloadUtil.getDownloadTracker(holder.itemView.getContext());
|
||||
|
||||
if (downloaderManager != null) {
|
||||
boolean isDownloaded = downloaderManager.isDownloaded(song.getId());
|
||||
|
||||
if (isDownloaded) {
|
||||
holder.item.downloadIndicatorIcon.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
holder.item.downloadIndicatorIcon.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
holder.item.downloadIndicatorIcon.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (Preferences.showItemRating()) {
|
||||
if (song.getStarred() == null && song.getUserRating() == null) {
|
||||
holder.item.ratingIndicatorImageView.setVisibility(View.GONE);
|
||||
|
||||
@@ -55,7 +55,7 @@ public class GithubTempoUpdateDialog extends DialogFragment {
|
||||
});
|
||||
|
||||
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener(v -> {
|
||||
Preferences.setTempoUpdateReminder();
|
||||
Preferences.setTempusUpdateReminder();
|
||||
Objects.requireNonNull(getDialog()).dismiss();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.dialog;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
@@ -10,6 +11,7 @@ import androidx.media3.common.MediaMetadata;
|
||||
import com.cappielloantonio.tempo.R;
|
||||
import com.cappielloantonio.tempo.databinding.DialogTrackInfoBinding;
|
||||
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
|
||||
import com.cappielloantonio.tempo.util.AssetLinkUtil;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
@@ -21,6 +23,11 @@ public class TrackInfoDialog extends DialogFragment {
|
||||
private DialogTrackInfoBinding bind;
|
||||
|
||||
private final MediaMetadata mediaMetadata;
|
||||
private AssetLinkUtil.AssetLink songLink;
|
||||
private AssetLinkUtil.AssetLink albumLink;
|
||||
private AssetLinkUtil.AssetLink artistLink;
|
||||
private AssetLinkUtil.AssetLink genreLink;
|
||||
private AssetLinkUtil.AssetLink yearLink;
|
||||
|
||||
public TrackInfoDialog(MediaMetadata mediaMetadata) {
|
||||
this.mediaMetadata = mediaMetadata;
|
||||
@@ -52,6 +59,8 @@ public class TrackInfoDialog extends DialogFragment {
|
||||
}
|
||||
|
||||
private void setTrackInfo() {
|
||||
genreLink = null;
|
||||
yearLink = null;
|
||||
bind.trakTitleInfoTextView.setText(mediaMetadata.title);
|
||||
bind.trakArtistInfoTextView.setText(
|
||||
mediaMetadata.artist != null
|
||||
@@ -61,17 +70,41 @@ public class TrackInfoDialog extends DialogFragment {
|
||||
: "");
|
||||
|
||||
if (mediaMetadata.extras != null) {
|
||||
songLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_SONG, mediaMetadata.extras.getString("id"));
|
||||
albumLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ALBUM, mediaMetadata.extras.getString("albumId"));
|
||||
artistLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ARTIST, mediaMetadata.extras.getString("artistId"));
|
||||
genreLink = AssetLinkUtil.parseLinkString(mediaMetadata.extras.getString("assetLinkGenre"));
|
||||
yearLink = AssetLinkUtil.parseLinkString(mediaMetadata.extras.getString("assetLinkYear"));
|
||||
|
||||
CustomGlideRequest.Builder
|
||||
.from(requireContext(), mediaMetadata.extras.getString("coverArtId", ""), CustomGlideRequest.ResourceType.Song)
|
||||
.build()
|
||||
.into(bind.trackCoverInfoImageView);
|
||||
|
||||
bind.titleValueSector.setText(mediaMetadata.extras.getString("title", getString(R.string.label_placeholder)));
|
||||
bind.albumValueSector.setText(mediaMetadata.extras.getString("album", getString(R.string.label_placeholder)));
|
||||
bind.artistValueSector.setText(mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder)));
|
||||
bindAssetLink(bind.trackCoverInfoImageView, albumLink != null ? albumLink : songLink);
|
||||
bindAssetLink(bind.trakTitleInfoTextView, songLink);
|
||||
bindAssetLink(bind.trakArtistInfoTextView, artistLink != null ? artistLink : songLink);
|
||||
|
||||
String titleValue = mediaMetadata.extras.getString("title", getString(R.string.label_placeholder));
|
||||
String albumValue = mediaMetadata.extras.getString("album", getString(R.string.label_placeholder));
|
||||
String artistValue = mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder));
|
||||
String genreValue = mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder));
|
||||
int yearValue = mediaMetadata.extras.getInt("year", 0);
|
||||
|
||||
if (genreLink == null && genreValue != null && !genreValue.isEmpty() && !getString(R.string.label_placeholder).contentEquals(genreValue)) {
|
||||
genreLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_GENRE, genreValue);
|
||||
}
|
||||
|
||||
if (yearLink == null && yearValue != 0) {
|
||||
yearLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(yearValue));
|
||||
}
|
||||
|
||||
bind.titleValueSector.setText(titleValue);
|
||||
bind.albumValueSector.setText(albumValue);
|
||||
bind.artistValueSector.setText(artistValue);
|
||||
bind.trackNumberValueSector.setText(mediaMetadata.extras.getInt("track", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("track", 0)) : getString(R.string.label_placeholder));
|
||||
bind.yearValueSector.setText(mediaMetadata.extras.getInt("year", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("year", 0)) : getString(R.string.label_placeholder));
|
||||
bind.genreValueSector.setText(mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder)));
|
||||
bind.yearValueSector.setText(yearValue != 0 ? String.valueOf(yearValue) : getString(R.string.label_placeholder));
|
||||
bind.genreValueSector.setText(genreValue);
|
||||
bind.sizeValueSector.setText(mediaMetadata.extras.getLong("size", 0) != 0 ? MusicUtil.getReadableByteCount(mediaMetadata.extras.getLong("size", 0)) : getString(R.string.label_placeholder));
|
||||
bind.contentTypeValueSector.setText(mediaMetadata.extras.getString("contentType", getString(R.string.label_placeholder)));
|
||||
bind.suffixValueSector.setText(mediaMetadata.extras.getString("suffix", getString(R.string.label_placeholder)));
|
||||
@@ -83,6 +116,12 @@ public class TrackInfoDialog extends DialogFragment {
|
||||
bind.bitDepthValueSector.setText(mediaMetadata.extras.getInt("bitDepth", 0) != 0 ? mediaMetadata.extras.getInt("bitDepth", 0) + " bits" : getString(R.string.label_placeholder));
|
||||
bind.pathValueSector.setText(mediaMetadata.extras.getString("path", getString(R.string.label_placeholder)));
|
||||
bind.discNumberValueSector.setText(mediaMetadata.extras.getInt("discNumber", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("discNumber", 0)) : getString(R.string.label_placeholder));
|
||||
|
||||
bindAssetLink(bind.titleValueSector, songLink);
|
||||
bindAssetLink(bind.albumValueSector, albumLink);
|
||||
bindAssetLink(bind.artistValueSector, artistLink);
|
||||
bindAssetLink(bind.genreValueSector, genreLink);
|
||||
bindAssetLink(bind.yearValueSector, yearLink);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,4 +174,31 @@ public class TrackInfoDialog extends DialogFragment {
|
||||
bind.trakTranscodingInfoTextView.setText(info);
|
||||
}
|
||||
}
|
||||
|
||||
private void bindAssetLink(android.view.View view, AssetLinkUtil.AssetLink assetLink) {
|
||||
if (view == null) return;
|
||||
if (assetLink == null) {
|
||||
AssetLinkUtil.clearLinkAppearance(view);
|
||||
view.setOnClickListener(null);
|
||||
view.setOnLongClickListener(null);
|
||||
view.setClickable(false);
|
||||
view.setLongClickable(false);
|
||||
return;
|
||||
}
|
||||
|
||||
view.setClickable(true);
|
||||
view.setLongClickable(true);
|
||||
AssetLinkUtil.applyLinkAppearance(view);
|
||||
view.setOnClickListener(v -> {
|
||||
dismissAllowingStateLoss();
|
||||
boolean collapse = !AssetLinkUtil.TYPE_SONG.equals(assetLink.type);
|
||||
((com.cappielloantonio.tempo.ui.activity.MainActivity) requireActivity()).openAssetLink(assetLink, collapse);
|
||||
});
|
||||
view.setOnLongClickListener(v -> {
|
||||
AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
|
||||
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, assetLink.id), Toast.LENGTH_SHORT).show();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.ui.fragment;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
@@ -32,26 +33,50 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.AlbumCatalogueViewModel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@OptIn(markerClass = UnstableApi.class)
|
||||
public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
|
||||
private static final String TAG = "ArtistCatalogueFragment";
|
||||
private static final String TAG = "AlbumCatalogueFragment";
|
||||
|
||||
private FragmentAlbumCatalogueBinding bind;
|
||||
private MainActivity activity;
|
||||
private AlbumCatalogueViewModel albumCatalogueViewModel;
|
||||
|
||||
private AlbumCatalogueAdapter albumAdapter;
|
||||
private String currentSortOrder;
|
||||
private List<com.cappielloantonio.tempo.subsonic.models.AlbumID3> originalAlbums;
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setHasOptionsMenu(true);
|
||||
currentSortOrder = Preferences.getAlbumSortOrder();
|
||||
|
||||
initData();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
String latestSort = Preferences.getAlbumSortOrder();
|
||||
|
||||
if (!latestSort.equals(currentSortOrder)) {
|
||||
currentSortOrder = latestSort;
|
||||
}
|
||||
// Re-apply sort when returning to fragment
|
||||
if (originalAlbums != null && currentSortOrder != null) {
|
||||
applySortToAlbums(currentSortOrder);
|
||||
} else {
|
||||
Log.d(TAG, "onResume - Cannot re-sort, missing data");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
@@ -115,7 +140,12 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
|
||||
albumAdapter = new AlbumCatalogueAdapter(this, true);
|
||||
albumAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY);
|
||||
bind.albumCatalogueRecyclerView.setAdapter(albumAdapter);
|
||||
albumCatalogueViewModel.getAlbumList().observe(getViewLifecycleOwner(), albums -> albumAdapter.setItems(albums));
|
||||
albumCatalogueViewModel.getAlbumList().observe(getViewLifecycleOwner(), albums -> {
|
||||
originalAlbums = albums;
|
||||
currentSortOrder = Preferences.getAlbumSortOrder();
|
||||
applySortToAlbums(currentSortOrder);
|
||||
updateSortIndicator();
|
||||
});
|
||||
|
||||
bind.albumCatalogueRecyclerView.setOnTouchListener((v, event) -> {
|
||||
hideKeyboard(v);
|
||||
@@ -125,6 +155,16 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
|
||||
bind.albumListSortImageView.setOnClickListener(view -> showPopupMenu(view, R.menu.sort_album_popup_menu));
|
||||
}
|
||||
|
||||
private void applySortToAlbums(String sortOrder) {
|
||||
if (originalAlbums == null) {
|
||||
return;
|
||||
}
|
||||
albumAdapter.setItemsWithoutFilter(originalAlbums);
|
||||
if (sortOrder != null) {
|
||||
albumAdapter.sort(sortOrder);
|
||||
}
|
||||
}
|
||||
|
||||
private void initProgressLoader() {
|
||||
albumCatalogueViewModel.getLoadingStatus().observe(getViewLifecycleOwner(), isLoading -> {
|
||||
if (isLoading) {
|
||||
@@ -137,6 +177,37 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
|
||||
});
|
||||
}
|
||||
|
||||
private void updateSortIndicator() {
|
||||
if (bind == null) return;
|
||||
|
||||
String sortText = getSortDisplayText(currentSortOrder);
|
||||
bind.albumListSortTextView.setText(sortText);
|
||||
bind.albumListSortTextView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
private String getSortDisplayText(String sortOrder) {
|
||||
if (sortOrder == null) return "";
|
||||
|
||||
switch (sortOrder) {
|
||||
case Constants.ALBUM_ORDER_BY_NAME:
|
||||
return getString(R.string.menu_sort_name);
|
||||
case Constants.ALBUM_ORDER_BY_ARTIST:
|
||||
return getString(R.string.menu_group_by_artist);
|
||||
case Constants.ALBUM_ORDER_BY_YEAR:
|
||||
return getString(R.string.menu_sort_year);
|
||||
case Constants.ALBUM_ORDER_BY_RANDOM:
|
||||
return getString(R.string.menu_sort_random);
|
||||
case Constants.ALBUM_ORDER_BY_RECENTLY_ADDED:
|
||||
return getString(R.string.menu_sort_recently_added);
|
||||
case Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED:
|
||||
return getString(R.string.menu_sort_recently_played);
|
||||
case Constants.ALBUM_ORDER_BY_MOST_PLAYED:
|
||||
return getString(R.string.menu_sort_most_played);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
|
||||
inflater.inflate(R.menu.toolbar_menu, menu);
|
||||
@@ -172,26 +243,29 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
|
||||
popup.getMenuInflater().inflate(menuResource, popup.getMenu());
|
||||
|
||||
popup.setOnMenuItemClickListener(menuItem -> {
|
||||
String newSortOrder = null;
|
||||
|
||||
if (menuItem.getItemId() == R.id.menu_album_sort_name) {
|
||||
albumAdapter.sort(Constants.ALBUM_ORDER_BY_NAME);
|
||||
return true;
|
||||
newSortOrder = Constants.ALBUM_ORDER_BY_NAME;
|
||||
} else if (menuItem.getItemId() == R.id.menu_album_sort_artist) {
|
||||
albumAdapter.sort(Constants.ALBUM_ORDER_BY_ARTIST);
|
||||
return true;
|
||||
newSortOrder = Constants.ALBUM_ORDER_BY_ARTIST;
|
||||
} else if (menuItem.getItemId() == R.id.menu_album_sort_year) {
|
||||
albumAdapter.sort(Constants.ALBUM_ORDER_BY_YEAR);
|
||||
return true;
|
||||
newSortOrder = Constants.ALBUM_ORDER_BY_YEAR;
|
||||
} else if (menuItem.getItemId() == R.id.menu_album_sort_random) {
|
||||
albumAdapter.sort(Constants.ALBUM_ORDER_BY_RANDOM);
|
||||
return true;
|
||||
newSortOrder = Constants.ALBUM_ORDER_BY_RANDOM;
|
||||
} else if (menuItem.getItemId() == R.id.menu_album_sort_recently_added) {
|
||||
albumAdapter.sort(Constants.ALBUM_ORDER_BY_RECENTLY_ADDED);
|
||||
return true;
|
||||
newSortOrder = Constants.ALBUM_ORDER_BY_RECENTLY_ADDED;
|
||||
} else if (menuItem.getItemId() == R.id.menu_album_sort_recently_played) {
|
||||
albumAdapter.sort(Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED);
|
||||
return true;
|
||||
newSortOrder = Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED;
|
||||
} else if (menuItem.getItemId() == R.id.menu_album_sort_most_played) {
|
||||
albumAdapter.sort(Constants.ALBUM_ORDER_BY_MOST_PLAYED);
|
||||
newSortOrder = Constants.ALBUM_ORDER_BY_MOST_PLAYED;
|
||||
}
|
||||
|
||||
if (newSortOrder != null) {
|
||||
currentSortOrder = newSortOrder;
|
||||
Preferences.setAlbumSortOrder(newSortOrder);
|
||||
applySortToAlbums(newSortOrder);
|
||||
updateSortIndicator();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
@@ -12,6 +12,7 @@ import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Toast;
|
||||
import android.widget.ToggleButton;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -35,6 +36,7 @@ import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
|
||||
import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
|
||||
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
|
||||
import com.cappielloantonio.tempo.util.AssetLinkUtil;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
@@ -59,12 +61,14 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||
private SongHorizontalAdapter songHorizontalAdapter;
|
||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||
|
||||
/** @noinspection deprecation*/
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setHasOptionsMenu(true);
|
||||
}
|
||||
|
||||
/** @noinspection deprecation*/
|
||||
@Override
|
||||
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
@@ -80,7 +84,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||
albumPageViewModel = new ViewModelProvider(requireActivity()).get(AlbumPageViewModel.class);
|
||||
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
|
||||
|
||||
init();
|
||||
init(view);
|
||||
initAppBar();
|
||||
initAlbumInfoTextButton();
|
||||
initAlbumNotes();
|
||||
@@ -118,12 +122,13 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||
bind = null;
|
||||
}
|
||||
|
||||
/** @noinspection deprecation*/
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
|
||||
if (item.getItemId() == R.id.action_rate_album) {
|
||||
Bundle bundle = new Bundle();
|
||||
AlbumID3 album = albumPageViewModel.getAlbum().getValue();
|
||||
bundle.putParcelable(Constants.ALBUM_OBJECT, (Parcelable) album);
|
||||
bundle.putParcelable(Constants.ALBUM_OBJECT, album);
|
||||
RatingDialog dialog = new RatingDialog();
|
||||
dialog.setArguments(bundle);
|
||||
dialog.show(requireActivity().getSupportFragmentManager(), null);
|
||||
@@ -158,8 +163,21 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||
return false;
|
||||
}
|
||||
|
||||
private void init() {
|
||||
albumPageViewModel.setAlbum(getViewLifecycleOwner(), requireArguments().getParcelable(Constants.ALBUM_OBJECT));
|
||||
private void init(View view) {
|
||||
AlbumID3 albumArg = requireArguments().getParcelable(Constants.ALBUM_OBJECT);
|
||||
assert albumArg != null;
|
||||
albumPageViewModel.setAlbum(getViewLifecycleOwner(), albumArg);
|
||||
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
|
||||
favoriteToggle.setChecked(albumArg.getStarred() != null);
|
||||
|
||||
favoriteToggle.setOnClickListener(v -> {
|
||||
albumPageViewModel.setFavorite();
|
||||
});
|
||||
albumPageViewModel.getAlbum().observe(getViewLifecycleOwner(), album -> {
|
||||
if (album != null) {
|
||||
favoriteToggle.setChecked(album.getStarred() != null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initAppBar() {
|
||||
@@ -177,8 +195,35 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||
|
||||
bind.albumNameLabel.setText(album.getName());
|
||||
bind.albumArtistLabel.setText(album.getArtist());
|
||||
AssetLinkUtil.applyLinkAppearance(bind.albumArtistLabel);
|
||||
AssetLinkUtil.AssetLink artistLink = buildArtistLink(album);
|
||||
bind.albumArtistLabel.setOnLongClickListener(v -> {
|
||||
if (artistLink != null) {
|
||||
AssetLinkUtil.copyToClipboard(requireContext(), artistLink);
|
||||
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, artistLink.id), Toast.LENGTH_SHORT).show();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
bind.albumReleaseYearLabel.setText(album.getYear() != 0 ? String.valueOf(album.getYear()) : "");
|
||||
bind.albumReleaseYearLabel.setVisibility(album.getYear() != 0 ? View.VISIBLE : View.GONE);
|
||||
if (album.getYear() != 0) {
|
||||
bind.albumReleaseYearLabel.setVisibility(View.VISIBLE);
|
||||
AssetLinkUtil.applyLinkAppearance(bind.albumReleaseYearLabel);
|
||||
bind.albumReleaseYearLabel.setOnClickListener(v -> openYearLink(album.getYear()));
|
||||
bind.albumReleaseYearLabel.setOnLongClickListener(v -> {
|
||||
AssetLinkUtil.AssetLink yearLink = buildYearLink(album.getYear());
|
||||
if (yearLink != null) {
|
||||
AssetLinkUtil.copyToClipboard(requireContext(), yearLink);
|
||||
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, yearLink.id), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
bind.albumReleaseYearLabel.setVisibility(View.GONE);
|
||||
bind.albumReleaseYearLabel.setOnClickListener(null);
|
||||
bind.albumReleaseYearLabel.setOnLongClickListener(null);
|
||||
AssetLinkUtil.clearLinkAppearance(bind.albumReleaseYearLabel);
|
||||
}
|
||||
bind.albumSongCountDurationTextview.setText(getString(R.string.album_page_tracks_count_and_duration, album.getSongCount(), album.getDuration() != null ? album.getDuration() / 60 : 0));
|
||||
if (album.getGenre() != null && !album.getGenre().isEmpty()) {
|
||||
bind.albumGenresTextview.setText(album.getGenre());
|
||||
@@ -220,6 +265,10 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||
bind.albumDetailView.setVisibility(View.GONE);
|
||||
}
|
||||
});
|
||||
|
||||
if(Preferences.showAlbumDetail()){
|
||||
bind.albumDetailView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void initAlbumInfoTextButton() {
|
||||
@@ -347,4 +396,23 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||
private void setMediaBrowserListenableFuture() {
|
||||
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
|
||||
}
|
||||
}
|
||||
|
||||
private void openYearLink(int year) {
|
||||
AssetLinkUtil.AssetLink link = buildYearLink(year);
|
||||
if (link != null) {
|
||||
activity.openAssetLink(link);
|
||||
}
|
||||
}
|
||||
|
||||
private AssetLinkUtil.AssetLink buildYearLink(int year) {
|
||||
if (year <= 0) return null;
|
||||
return AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(year));
|
||||
}
|
||||
|
||||
private AssetLinkUtil.AssetLink buildArtistLink(AlbumID3 album) {
|
||||
if (album == null || album.getArtistId() == null || album.getArtistId().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ARTIST, album.getArtistId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
import com.cappielloantonio.tempo.ui.adapter.ArtistCatalogueAdapter;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.ArtistCatalogueViewModel;
|
||||
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
|
||||
|
||||
@@ -114,7 +115,10 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
|
||||
artistAdapter = new ArtistCatalogueAdapter(this);
|
||||
artistAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY);
|
||||
bind.artistCatalogueRecyclerView.setAdapter(artistAdapter);
|
||||
artistCatalogueViewModel.getArtistList().observe(getViewLifecycleOwner(), artistList -> artistAdapter.setItems(artistList));
|
||||
artistCatalogueViewModel.getArtistList().observe(getViewLifecycleOwner(), artistList -> {
|
||||
artistAdapter.setItems(artistList);
|
||||
artistAdapter.sort(Preferences.getArtistSortOrder());
|
||||
});
|
||||
|
||||
bind.artistCatalogueRecyclerView.setOnTouchListener((v, event) -> {
|
||||
hideKeyboard(v);
|
||||
@@ -192,6 +196,9 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
|
||||
} else if (menuItem.getItemId() == R.id.menu_artist_sort_random) {
|
||||
artistAdapter.sort(Constants.ARTIST_ORDER_BY_RANDOM);
|
||||
return true;
|
||||
} else if (menuItem.getItemId() == R.id.menu_artist_sort_album_count) {
|
||||
artistAdapter.sort(Constants.ARTIST_ORDER_BY_ALBUM_COUNT);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -2,14 +2,18 @@ package com.cappielloantonio.tempo.ui.fragment;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Toast;
|
||||
import android.widget.ToggleButton;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
@@ -40,6 +44,7 @@ import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@UnstableApi
|
||||
public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||
@@ -63,7 +68,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||
artistPageViewModel = new ViewModelProvider(requireActivity()).get(ArtistPageViewModel.class);
|
||||
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
|
||||
|
||||
init();
|
||||
init(view);
|
||||
initAppBar();
|
||||
initArtistInfo();
|
||||
initPlayButtons();
|
||||
@@ -100,7 +105,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||
bind = null;
|
||||
}
|
||||
|
||||
private void init() {
|
||||
private void init(View view) {
|
||||
artistPageViewModel.setArtist(requireArguments().getParcelable(Constants.ARTIST_OBJECT));
|
||||
|
||||
bind.mostStreamedSongTextViewClickable.setOnClickListener(v -> {
|
||||
@@ -109,6 +114,10 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||
bundle.putParcelable(Constants.ARTIST_OBJECT, artistPageViewModel.getArtist());
|
||||
activity.navController.navigate(R.id.action_artistPageFragment_to_songListPageFragment, bundle);
|
||||
});
|
||||
|
||||
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
|
||||
favoriteToggle.setChecked(artistPageViewModel.getArtist().getStarred() != null);
|
||||
favoriteToggle.setOnClickListener(v -> artistPageViewModel.setFavorite(requireContext()));
|
||||
}
|
||||
|
||||
private void initAppBar() {
|
||||
@@ -133,10 +142,54 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||
if (bind != null)
|
||||
bind.bioMoreTextViewClickable.setVisibility(artistInfo.getLastFmUrl() != null ? View.VISIBLE : View.GONE);
|
||||
|
||||
if (getContext() != null && bind != null) CustomGlideRequest.Builder
|
||||
.from(requireContext(), artistPageViewModel.getArtist().getId(), CustomGlideRequest.ResourceType.Artist)
|
||||
.build()
|
||||
.into(bind.artistBackdropImageView);
|
||||
if (getContext() != null && bind != null) {
|
||||
ArtistID3 currentArtist = artistPageViewModel.getArtist();
|
||||
String primaryId = currentArtist.getCoverArtId() != null && !currentArtist.getCoverArtId().trim().isEmpty()
|
||||
? currentArtist.getCoverArtId()
|
||||
: currentArtist.getId();
|
||||
|
||||
final String fallbackId = (Objects.requireNonNull(primaryId).equals(currentArtist.getCoverArtId()) &&
|
||||
currentArtist.getId() != null &&
|
||||
!currentArtist.getId().equals(primaryId))
|
||||
? currentArtist.getId()
|
||||
: null;
|
||||
|
||||
CustomGlideRequest.Builder
|
||||
.from(requireContext(), primaryId, CustomGlideRequest.ResourceType.Artist)
|
||||
.build()
|
||||
.listener(new com.bumptech.glide.request.RequestListener<Drawable>() {
|
||||
@Override
|
||||
public boolean onLoadFailed(@Nullable com.bumptech.glide.load.engine.GlideException e,
|
||||
Object model,
|
||||
@NonNull com.bumptech.glide.request.target.Target<Drawable> target,
|
||||
boolean isFirstResource) {
|
||||
if (e != null) {
|
||||
e.getMessage();
|
||||
if (e.getMessage().contains("400") && fallbackId != null) {
|
||||
|
||||
Log.d("ArtistCover", "Primary ID failed (400), trying fallback: " + fallbackId);
|
||||
|
||||
CustomGlideRequest.Builder
|
||||
.from(requireContext(), fallbackId, CustomGlideRequest.ResourceType.Artist)
|
||||
.build()
|
||||
.into(bind.artistBackdropImageView);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onResourceReady(@NonNull Drawable resource,
|
||||
@NonNull Object model,
|
||||
com.bumptech.glide.request.target.Target<Drawable> target,
|
||||
@NonNull com.bumptech.glide.load.DataSource dataSource,
|
||||
boolean isFirstResource) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.into(bind.artistBackdropImageView);
|
||||
}
|
||||
|
||||
if (bind != null) bind.bioTextView.setText(normalizedBio);
|
||||
|
||||
@@ -150,29 +203,24 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initPlayButtons() {
|
||||
bind.artistPageShuffleButton.setOnClickListener(v -> {
|
||||
artistPageViewModel.getArtistShuffleList().observe(getViewLifecycleOwner(), songs -> {
|
||||
if (!songs.isEmpty()) {
|
||||
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
|
||||
activity.setBottomSheetInPeek(true);
|
||||
} else {
|
||||
Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_tracks), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
});
|
||||
bind.artistPageShuffleButton.setOnClickListener(v -> artistPageViewModel.getArtistShuffleList().observe(getViewLifecycleOwner(), songs -> {
|
||||
if (!songs.isEmpty()) {
|
||||
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
|
||||
activity.setBottomSheetInPeek(true);
|
||||
} else {
|
||||
Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_tracks), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}));
|
||||
|
||||
bind.artistPageRadioButton.setOnClickListener(v -> {
|
||||
artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), songs -> {
|
||||
if (songs != null && !songs.isEmpty()) {
|
||||
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
|
||||
activity.setBottomSheetInPeek(true);
|
||||
} else {
|
||||
Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_radio), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
});
|
||||
bind.artistPageRadioButton.setOnClickListener(v -> artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), songs -> {
|
||||
if (songs != null && !songs.isEmpty()) {
|
||||
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
|
||||
activity.setBottomSheetInPeek(true);
|
||||
} else {
|
||||
Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_radio), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void initTopSongsView() {
|
||||
@@ -188,8 +236,6 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||
} else {
|
||||
if (bind != null)
|
||||
bind.artistPageTopSongsSector.setVisibility(!songs.isEmpty() ? View.VISIBLE : View.GONE);
|
||||
if (bind != null)
|
||||
bind.artistPageShuffleButton.setEnabled(!songs.isEmpty());
|
||||
songHorizontalAdapter.setItems(songs);
|
||||
reapplyPlayback();
|
||||
}
|
||||
|
||||
@@ -27,7 +27,13 @@ import com.cappielloantonio.tempo.interfaces.DialogClickCallback;
|
||||
import com.cappielloantonio.tempo.model.Download;
|
||||
import com.cappielloantonio.tempo.service.MediaManager;
|
||||
import com.cappielloantonio.tempo.service.MediaService;
|
||||
import com.cappielloantonio.tempo.repository.DirectoryRepository;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Directory;
|
||||
import android.widget.Toast;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
import com.cappielloantonio.tempo.ui.adapter.MusicDirectoryAdapter;
|
||||
import com.cappielloantonio.tempo.ui.dialog.DownloadDirectoryDialog;
|
||||
@@ -53,6 +59,7 @@ public class DirectoryFragment extends Fragment implements ClickCallback {
|
||||
private MusicDirectoryAdapter musicDirectoryAdapter;
|
||||
|
||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||
private DirectoryRepository directoryRepository;
|
||||
|
||||
private MenuItem menuItem;
|
||||
|
||||
@@ -77,6 +84,7 @@ public class DirectoryFragment extends Fragment implements ClickCallback {
|
||||
bind = FragmentDirectoryBinding.inflate(inflater, container, false);
|
||||
View view = bind.getRoot();
|
||||
directoryViewModel = new ViewModelProvider(requireActivity()).get(DirectoryViewModel.class);
|
||||
directoryRepository = new DirectoryRepository();
|
||||
|
||||
initAppBar();
|
||||
initDirectoryListView();
|
||||
@@ -197,4 +205,57 @@ public class DirectoryFragment extends Fragment implements ClickCallback {
|
||||
public void onMusicDirectoryClick(Bundle bundle) {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.directoryFragment, bundle);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMusicDirectoryPlay(Bundle bundle) {
|
||||
String directoryId = bundle.getString(Constants.MUSIC_DIRECTORY_ID);
|
||||
if (directoryId != null) {
|
||||
Toast.makeText(requireContext(), getString(R.string.folder_play_collecting), Toast.LENGTH_SHORT).show();
|
||||
collectAndPlayDirectorySongs(directoryId);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectAndPlayDirectorySongs(String directoryId) {
|
||||
List<Child> allSongs = new ArrayList<>();
|
||||
AtomicInteger pendingRequests = new AtomicInteger(0);
|
||||
|
||||
collectSongsFromDirectory(directoryId, allSongs, pendingRequests, () -> {
|
||||
if (!allSongs.isEmpty()) {
|
||||
activity.runOnUiThread(() -> {
|
||||
MediaManager.startQueue(mediaBrowserListenableFuture, allSongs, 0);
|
||||
activity.setBottomSheetInPeek(true);
|
||||
Toast.makeText(requireContext(), getString(R.string.folder_play_playing, allSongs.size()), Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
} else {
|
||||
activity.runOnUiThread(() -> {
|
||||
Toast.makeText(requireContext(), getString(R.string.folder_play_no_songs), Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void collectSongsFromDirectory(String directoryId, List<Child> allSongs, AtomicInteger pendingRequests, Runnable onComplete) {
|
||||
pendingRequests.incrementAndGet();
|
||||
|
||||
directoryRepository.getMusicDirectory(directoryId).observe(getViewLifecycleOwner(), directory -> {
|
||||
if (directory != null && directory.getChildren() != null) {
|
||||
for (Child child : directory.getChildren()) {
|
||||
if (child.isDir()) {
|
||||
// It's a subdirectory, recurse into it
|
||||
collectSongsFromDirectory(child.getId(), allSongs, pendingRequests, onComplete);
|
||||
} else if (!child.isVideo()) {
|
||||
// It's a song, add it to the list
|
||||
synchronized (allSongs) {
|
||||
allSongs.add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decrement pending requests and check if we're done
|
||||
if (pendingRequests.decrementAndGet() == 0) {
|
||||
onComplete.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -117,14 +117,12 @@ public class DownloadFragment extends Fragment implements ClickCallback {
|
||||
if (songs.isEmpty()) {
|
||||
if (bind != null) {
|
||||
bind.emptyDownloadLayout.setVisibility(View.VISIBLE);
|
||||
bind.fragmentDownloadNestedScrollView.setVisibility(View.GONE);
|
||||
bind.downloadDownloadedSector.setVisibility(View.GONE);
|
||||
bind.downloadedGroupByImageView.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
if (bind != null) {
|
||||
bind.emptyDownloadLayout.setVisibility(View.GONE);
|
||||
bind.fragmentDownloadNestedScrollView.setVisibility(View.VISIBLE);
|
||||
bind.downloadDownloadedSector.setVisibility(View.VISIBLE);
|
||||
bind.downloadedGroupByImageView.setVisibility(View.VISIBLE);
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.cappielloantonio.tempo.ui.fragment
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.content.BroadcastReceiver
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.view.Gravity
|
||||
@@ -12,10 +14,12 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.cappielloantonio.tempo.R
|
||||
import com.cappielloantonio.tempo.service.EqualizerManager
|
||||
import com.cappielloantonio.tempo.service.BaseMediaService
|
||||
import com.cappielloantonio.tempo.service.MediaService
|
||||
import com.cappielloantonio.tempo.util.Preferences
|
||||
|
||||
@@ -28,10 +32,21 @@ class EqualizerFragment : Fragment() {
|
||||
private lateinit var safeSpace: Space
|
||||
private val bandSeekBars = mutableListOf<SeekBar>()
|
||||
|
||||
private var receiverRegistered = false
|
||||
private val equalizerUpdatedReceiver = object : BroadcastReceiver() {
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == BaseMediaService.ACTION_EQUALIZER_UPDATED) {
|
||||
initUI()
|
||||
restoreEqualizerPreferences()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
@OptIn(UnstableApi::class)
|
||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||
val binder = service as MediaService.LocalBinder
|
||||
val binder = service as BaseMediaService.LocalBinder
|
||||
equalizerManager = binder.getEqualizerManager()
|
||||
initUI()
|
||||
restoreEqualizerPreferences()
|
||||
@@ -46,15 +61,32 @@ class EqualizerFragment : Fragment() {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
Intent(requireContext(), MediaService::class.java).also { intent ->
|
||||
intent.action = MediaService.ACTION_BIND_EQUALIZER
|
||||
intent.action = BaseMediaService.ACTION_BIND_EQUALIZER
|
||||
requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
if (!receiverRegistered) {
|
||||
ContextCompat.registerReceiver(
|
||||
requireContext(),
|
||||
equalizerUpdatedReceiver,
|
||||
IntentFilter(BaseMediaService.ACTION_EQUALIZER_UPDATED),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
receiverRegistered = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
requireActivity().unbindService(connection)
|
||||
equalizerManager = null
|
||||
if (receiverRegistered) {
|
||||
try {
|
||||
requireContext().unregisterReceiver(equalizerUpdatedReceiver)
|
||||
} catch (_: Exception) {
|
||||
// ignore if not registered
|
||||
}
|
||||
receiverRegistered = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
@@ -234,4 +266,4 @@ class EqualizerFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun Int.dpToPx(context: Context): Int =
|
||||
(this * context.resources.displayMetrics.density).toInt()
|
||||
(this * context.resources.displayMetrics.density).toInt()
|
||||
|
||||
@@ -38,10 +38,10 @@ import com.cappielloantonio.tempo.model.HomeSector;
|
||||
import com.cappielloantonio.tempo.service.DownloaderManager;
|
||||
import com.cappielloantonio.tempo.service.MediaManager;
|
||||
import com.cappielloantonio.tempo.service.MediaService;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Share;
|
||||
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
|
||||
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Share;
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter;
|
||||
import com.cappielloantonio.tempo.ui.adapter.AlbumHorizontalAdapter;
|
||||
@@ -57,6 +57,8 @@ import com.cappielloantonio.tempo.ui.dialog.HomeRearrangementDialog;
|
||||
import com.cappielloantonio.tempo.ui.dialog.PlaylistEditorDialog;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
@@ -66,8 +68,6 @@ import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import androidx.media3.common.MediaItem;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -279,51 +279,113 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
}
|
||||
|
||||
private void initSyncStarredView() {
|
||||
if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
|
||||
homeViewModel.getAllStarredTracks().observeForever(new Observer<List<Child>>() {
|
||||
if (Preferences.isStarredSyncEnabled()) {
|
||||
homeViewModel.getAllStarredTracks().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> songs) {
|
||||
if (songs != null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
List<String> toSync = new ArrayList<>();
|
||||
if (songs != null && !songs.isEmpty()) {
|
||||
int songsToSyncCount = 0;
|
||||
List<String> toSyncSample = new ArrayList<>();
|
||||
|
||||
for (Child song : songs) {
|
||||
if (!manager.isDownloaded(song.getId())) {
|
||||
toSync.add(song.getTitle());
|
||||
}
|
||||
}
|
||||
|
||||
if (!toSync.isEmpty()) {
|
||||
bind.homeSyncStarredCard.setVisibility(View.VISIBLE);
|
||||
bind.homeSyncStarredTracksToSync.setText(String.join(", ", toSync));
|
||||
}
|
||||
}
|
||||
|
||||
homeViewModel.getAllStarredTracks().removeObserver(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bind.homeSyncStarredCancel.setOnClickListener(v -> bind.homeSyncStarredCard.setVisibility(View.GONE));
|
||||
|
||||
bind.homeSyncStarredDownload.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
homeViewModel.getAllStarredTracks().observeForever(new Observer<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> songs) {
|
||||
if (songs != null) {
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
|
||||
for (Child song : songs) {
|
||||
if (!manager.isDownloaded(song.getId())) {
|
||||
manager.download(MappingUtil.mapDownload(song), new Download(song));
|
||||
songsToSyncCount++;
|
||||
if (toSyncSample.size() < 3) {
|
||||
toSyncSample.add(song.getTitle());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (Child song : songs) {
|
||||
if (ExternalAudioReader.getUri(song) == null) {
|
||||
songsToSyncCount++;
|
||||
if (toSyncSample.size() < 3) {
|
||||
toSyncSample.add(song.getTitle());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
homeViewModel.getAllStarredTracks().removeObserver(this);
|
||||
if (songsToSyncCount > 0) {
|
||||
bind.homeSyncStarredCard.setVisibility(View.VISIBLE);
|
||||
|
||||
StringBuilder displayText = new StringBuilder();
|
||||
if (!toSyncSample.isEmpty()) {
|
||||
displayText.append(String.join(", ", toSyncSample));
|
||||
if (songsToSyncCount > 3) {
|
||||
displayText.append("...");
|
||||
}
|
||||
}
|
||||
|
||||
String countText = getResources().getQuantityString(
|
||||
R.plurals.home_sync_starred_songs_count,
|
||||
songsToSyncCount,
|
||||
songsToSyncCount
|
||||
);
|
||||
|
||||
if (displayText.length() > 0) {
|
||||
bind.homeSyncStarredTracksToSync.setText(displayText.toString() + "\n" + countText);
|
||||
} else {
|
||||
bind.homeSyncStarredTracksToSync.setText(countText);
|
||||
}
|
||||
|
||||
if (getActivity() != null) {
|
||||
getActivity().runOnUiThread(() -> reorder());
|
||||
}
|
||||
} else {
|
||||
bind.homeSyncStarredCard.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bind.homeSyncStarredCancel.setOnClickListener(v -> {
|
||||
bind.homeSyncStarredCard.setVisibility(View.GONE);
|
||||
if (getActivity() != null) {
|
||||
getActivity().runOnUiThread(() -> reorder());
|
||||
}
|
||||
});
|
||||
|
||||
bind.homeSyncStarredDownload.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
homeViewModel.getAllStarredTracks().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> songs) {
|
||||
if (songs != null && !songs.isEmpty()) {
|
||||
int downloadedCount = 0;
|
||||
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
for (Child song : songs) {
|
||||
if (!manager.isDownloaded(song.getId())) {
|
||||
manager.download(MappingUtil.mapDownload(song), new Download(song));
|
||||
downloadedCount++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (Child song : songs) {
|
||||
if (ExternalAudioReader.getUri(song) == null) {
|
||||
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
|
||||
downloadedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadedCount > 0) {
|
||||
Toast.makeText(requireContext(),
|
||||
getResources().getQuantityString(R.plurals.songs_download_started, downloadedCount, downloadedCount),
|
||||
Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
bind.homeSyncStarredCard.setVisibility(View.GONE);
|
||||
if (getActivity() != null) {
|
||||
getActivity().runOnUiThread(() -> reorder());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -331,6 +393,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
}
|
||||
|
||||
private void initSyncStarredAlbumsView() {
|
||||
|
||||
if (Preferences.isStarredAlbumsSyncEnabled()) {
|
||||
homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer<List<AlbumID3>>() {
|
||||
@Override
|
||||
@@ -344,6 +407,9 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
|
||||
bind.homeSyncStarredAlbumsCancel.setOnClickListener(v -> {
|
||||
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
|
||||
if (getActivity() != null) {
|
||||
getActivity().runOnUiThread(() -> reorder());
|
||||
}
|
||||
});
|
||||
|
||||
bind.homeSyncStarredAlbumsDownload.setOnClickListener(v -> {
|
||||
@@ -351,24 +417,36 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
@Override
|
||||
public void onChanged(List<Child> allSongs) {
|
||||
if (allSongs != null && !allSongs.isEmpty()) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
int songsToDownload = 0;
|
||||
|
||||
for (Child song : allSongs) {
|
||||
if (!manager.isDownloaded(song.getId())) {
|
||||
manager.download(MappingUtil.mapDownload(song), new Download(song));
|
||||
songsToDownload++;
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
for (Child song : allSongs) {
|
||||
if (!manager.isDownloaded(song.getId())) {
|
||||
manager.download(MappingUtil.mapDownload(song), new Download(song));
|
||||
songsToDownload++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (Child song : allSongs) {
|
||||
if (ExternalAudioReader.getUri(song) == null) {
|
||||
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
|
||||
songsToDownload++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (songsToDownload > 0) {
|
||||
Toast.makeText(requireContext(),
|
||||
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
|
||||
Toast.makeText(requireContext(),
|
||||
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
|
||||
Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
|
||||
if (getActivity() != null) {
|
||||
getActivity().runOnUiThread(() -> reorder());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -379,33 +457,73 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
@Override
|
||||
public void onChanged(List<Child> allSongs) {
|
||||
if (allSongs != null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
int songsToDownload = 0;
|
||||
List<String> albumsNeedingSync = new ArrayList<>();
|
||||
|
||||
for (AlbumID3 album : albums) {
|
||||
boolean albumNeedsSync = false;
|
||||
// Check if any songs from this album need downloading
|
||||
for (Child song : allSongs) {
|
||||
if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) &&
|
||||
!manager.isDownloaded(song.getId())) {
|
||||
songsToDownload++;
|
||||
albumNeedsSync = true;
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
|
||||
for (AlbumID3 album : albums) {
|
||||
boolean albumNeedsSync = false;
|
||||
for (Child song : allSongs) {
|
||||
if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) &&
|
||||
!manager.isDownloaded(song.getId())) {
|
||||
songsToDownload++;
|
||||
albumNeedsSync = true;
|
||||
}
|
||||
}
|
||||
if (albumNeedsSync) {
|
||||
albumsNeedingSync.add(album.getName());
|
||||
}
|
||||
}
|
||||
if (albumNeedsSync) {
|
||||
albumsNeedingSync.add(album.getName());
|
||||
} else {
|
||||
for (AlbumID3 album : albums) {
|
||||
boolean albumNeedsSync = false;
|
||||
for (Child song : allSongs) {
|
||||
if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) &&
|
||||
ExternalAudioReader.getUri(song) == null) {
|
||||
songsToDownload++;
|
||||
albumNeedsSync = true;
|
||||
}
|
||||
}
|
||||
if (albumNeedsSync) {
|
||||
albumsNeedingSync.add(album.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (songsToDownload > 0) {
|
||||
bind.homeSyncStarredAlbumsCard.setVisibility(View.VISIBLE);
|
||||
String message = getResources().getQuantityString(
|
||||
R.plurals.home_sync_starred_albums_count,
|
||||
albumsNeedingSync.size(),
|
||||
|
||||
StringBuilder displayText = new StringBuilder();
|
||||
List<String> sampleAlbums = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < Math.min(albumsNeedingSync.size(), 3); i++) {
|
||||
sampleAlbums.add(albumsNeedingSync.get(i));
|
||||
}
|
||||
|
||||
if (!sampleAlbums.isEmpty()) {
|
||||
displayText.append(String.join(", ", sampleAlbums));
|
||||
if (albumsNeedingSync.size() > 3) {
|
||||
displayText.append("...");
|
||||
}
|
||||
}
|
||||
|
||||
String countText = getResources().getQuantityString(
|
||||
R.plurals.home_sync_starred_albums_count,
|
||||
albumsNeedingSync.size(),
|
||||
albumsNeedingSync.size()
|
||||
);
|
||||
bind.homeSyncStarredAlbumsToSync.setText(message);
|
||||
|
||||
if (displayText.length() > 0) {
|
||||
bind.homeSyncStarredAlbumsToSync.setText(displayText.toString() + "\n" + countText);
|
||||
} else {
|
||||
bind.homeSyncStarredAlbumsToSync.setText(countText);
|
||||
}
|
||||
|
||||
if (getActivity() != null) {
|
||||
getActivity().runOnUiThread(() -> reorder());
|
||||
}
|
||||
} else {
|
||||
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
|
||||
}
|
||||
@@ -428,6 +546,9 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
|
||||
bind.homeSyncStarredArtistsCancel.setOnClickListener(v -> {
|
||||
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
|
||||
if (getActivity() != null) {
|
||||
getActivity().runOnUiThread(() -> reorder());
|
||||
}
|
||||
});
|
||||
|
||||
bind.homeSyncStarredArtistsDownload.setOnClickListener(v -> {
|
||||
@@ -435,24 +556,36 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
@Override
|
||||
public void onChanged(List<Child> allSongs) {
|
||||
if (allSongs != null && !allSongs.isEmpty()) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
int songsToDownload = 0;
|
||||
|
||||
for (Child song : allSongs) {
|
||||
if (!manager.isDownloaded(song.getId())) {
|
||||
manager.download(MappingUtil.mapDownload(song), new Download(song));
|
||||
songsToDownload++;
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
for (Child song : allSongs) {
|
||||
if (!manager.isDownloaded(song.getId())) {
|
||||
manager.download(MappingUtil.mapDownload(song), new Download(song));
|
||||
songsToDownload++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (Child song : allSongs) {
|
||||
if (ExternalAudioReader.getUri(song) == null) {
|
||||
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
|
||||
songsToDownload++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (songsToDownload > 0) {
|
||||
Toast.makeText(requireContext(),
|
||||
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
|
||||
Toast.makeText(requireContext(),
|
||||
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
|
||||
Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
|
||||
if (getActivity() != null) {
|
||||
getActivity().runOnUiThread(() -> reorder());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -463,33 +596,73 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
@Override
|
||||
public void onChanged(List<Child> allSongs) {
|
||||
if (allSongs != null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
int songsToDownload = 0;
|
||||
List<String> artistsNeedingSync = new ArrayList<>();
|
||||
|
||||
for (ArtistID3 artist : artists) {
|
||||
boolean artistNeedsSync = false;
|
||||
// Check if any songs from this artist need downloading
|
||||
for (Child song : allSongs) {
|
||||
if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) &&
|
||||
!manager.isDownloaded(song.getId())) {
|
||||
songsToDownload++;
|
||||
artistNeedsSync = true;
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
|
||||
for (ArtistID3 artist : artists) {
|
||||
boolean artistNeedsSync = false;
|
||||
for (Child song : allSongs) {
|
||||
if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) &&
|
||||
!manager.isDownloaded(song.getId())) {
|
||||
songsToDownload++;
|
||||
artistNeedsSync = true;
|
||||
}
|
||||
}
|
||||
if (artistNeedsSync) {
|
||||
artistsNeedingSync.add(artist.getName());
|
||||
}
|
||||
}
|
||||
if (artistNeedsSync) {
|
||||
artistsNeedingSync.add(artist.getName());
|
||||
} else {
|
||||
for (ArtistID3 artist : artists) {
|
||||
boolean artistNeedsSync = false;
|
||||
for (Child song : allSongs) {
|
||||
if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) &&
|
||||
ExternalAudioReader.getUri(song) == null) {
|
||||
songsToDownload++;
|
||||
artistNeedsSync = true;
|
||||
}
|
||||
}
|
||||
if (artistNeedsSync) {
|
||||
artistsNeedingSync.add(artist.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (songsToDownload > 0) {
|
||||
bind.homeSyncStarredArtistsCard.setVisibility(View.VISIBLE);
|
||||
String message = getResources().getQuantityString(
|
||||
R.plurals.home_sync_starred_artists_count,
|
||||
artistsNeedingSync.size(),
|
||||
|
||||
StringBuilder displayText = new StringBuilder();
|
||||
List<String> sampleArtists = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < Math.min(artistsNeedingSync.size(), 3); i++) {
|
||||
sampleArtists.add(artistsNeedingSync.get(i));
|
||||
}
|
||||
|
||||
if (!sampleArtists.isEmpty()) {
|
||||
displayText.append(String.join(", ", sampleArtists));
|
||||
if (artistsNeedingSync.size() > 3) {
|
||||
displayText.append("...");
|
||||
}
|
||||
}
|
||||
|
||||
String countText = getResources().getQuantityString(
|
||||
R.plurals.home_sync_starred_artists_count,
|
||||
artistsNeedingSync.size(),
|
||||
artistsNeedingSync.size()
|
||||
);
|
||||
bind.homeSyncStarredArtistsToSync.setText(message);
|
||||
|
||||
if (displayText.length() > 0) {
|
||||
bind.homeSyncStarredArtistsToSync.setText(displayText.toString() + "\n" + countText);
|
||||
} else {
|
||||
bind.homeSyncStarredArtistsToSync.setText(countText);
|
||||
}
|
||||
|
||||
if (getActivity() != null) {
|
||||
getActivity().runOnUiThread(() -> reorder());
|
||||
}
|
||||
} else {
|
||||
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
|
||||
}
|
||||
@@ -497,7 +670,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private void initDiscoverSongSlideView() {
|
||||
if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_DISCOVERY)) return;
|
||||
|
||||
@@ -962,6 +1135,18 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||
if (bind != null && homeViewModel.getHomeSectorList() != null) {
|
||||
bind.homeLinearLayoutContainer.removeAllViews();
|
||||
|
||||
if (bind.homeSyncStarredCard.getVisibility() == View.VISIBLE) {
|
||||
bind.homeLinearLayoutContainer.addView(bind.homeSyncStarredCard);
|
||||
}
|
||||
|
||||
if (bind.homeSyncStarredAlbumsCard.getVisibility() == View.VISIBLE) {
|
||||
bind.homeLinearLayoutContainer.addView(bind.homeSyncStarredAlbumsCard);
|
||||
}
|
||||
|
||||
if (bind.homeSyncStarredArtistsCard.getVisibility() == View.VISIBLE) {
|
||||
bind.homeLinearLayoutContainer.addView(bind.homeSyncStarredArtistsCard);
|
||||
}
|
||||
|
||||
for (HomeSector sector : homeViewModel.getHomeSectorList()) {
|
||||
if (!sector.isVisible()) continue;
|
||||
|
||||
|
||||
@@ -1,27 +1,40 @@
|
||||
package com.cappielloantonio.tempo.ui.fragment;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.session.MediaBrowser;
|
||||
import androidx.media3.session.SessionToken;
|
||||
import androidx.navigation.Navigation;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import com.cappielloantonio.tempo.R;
|
||||
import com.cappielloantonio.tempo.databinding.FragmentIndexBinding;
|
||||
import com.cappielloantonio.tempo.interfaces.ClickCallback;
|
||||
import com.cappielloantonio.tempo.repository.DirectoryRepository;
|
||||
import com.cappielloantonio.tempo.service.MediaManager;
|
||||
import com.cappielloantonio.tempo.service.MediaService;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.MusicFolder;
|
||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
import com.cappielloantonio.tempo.ui.adapter.MusicIndexAdapter;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.IndexUtil;
|
||||
import com.cappielloantonio.tempo.viewmodel.IndexViewModel;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
@UnstableApi
|
||||
public class IndexFragment extends Fragment implements ClickCallback {
|
||||
@@ -32,6 +45,8 @@ public class IndexFragment extends Fragment implements ClickCallback {
|
||||
private IndexViewModel indexViewModel;
|
||||
|
||||
private MusicIndexAdapter musicIndexAdapter;
|
||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||
private DirectoryRepository directoryRepository;
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
@@ -40,6 +55,7 @@ public class IndexFragment extends Fragment implements ClickCallback {
|
||||
bind = FragmentIndexBinding.inflate(inflater, container, false);
|
||||
View view = bind.getRoot();
|
||||
indexViewModel = new ViewModelProvider(requireActivity()).get(IndexViewModel.class);
|
||||
directoryRepository = new DirectoryRepository();
|
||||
|
||||
initAppBar();
|
||||
initDirectoryListView();
|
||||
@@ -48,6 +64,18 @@ public class IndexFragment extends Fragment implements ClickCallback {
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
initializeMediaBrowser();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStop() {
|
||||
releaseMediaBrowser();
|
||||
super.onStop();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
@@ -107,4 +135,65 @@ public class IndexFragment extends Fragment implements ClickCallback {
|
||||
public void onMusicIndexClick(Bundle bundle) {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.directoryFragment, bundle);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMusicIndexPlay(Bundle bundle) {
|
||||
String directoryId = bundle.getString(Constants.MUSIC_DIRECTORY_ID);
|
||||
if (directoryId != null) {
|
||||
Toast.makeText(requireContext(), getString(R.string.folder_play_collecting), Toast.LENGTH_SHORT).show();
|
||||
collectAndPlayDirectorySongs(directoryId);
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeMediaBrowser() {
|
||||
mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync();
|
||||
}
|
||||
|
||||
private void releaseMediaBrowser() {
|
||||
MediaBrowser.releaseFuture(mediaBrowserListenableFuture);
|
||||
}
|
||||
|
||||
private void collectAndPlayDirectorySongs(String directoryId) {
|
||||
List<Child> allSongs = new ArrayList<>();
|
||||
AtomicInteger pendingRequests = new AtomicInteger(0);
|
||||
|
||||
collectSongsFromDirectory(directoryId, allSongs, pendingRequests, () -> {
|
||||
if (!allSongs.isEmpty()) {
|
||||
activity.runOnUiThread(() -> {
|
||||
MediaManager.startQueue(mediaBrowserListenableFuture, allSongs, 0);
|
||||
activity.setBottomSheetInPeek(true);
|
||||
Toast.makeText(requireContext(), getString(R.string.folder_play_playing, allSongs.size()), Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
} else {
|
||||
activity.runOnUiThread(() -> {
|
||||
Toast.makeText(requireContext(), getString(R.string.folder_play_no_songs), Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void collectSongsFromDirectory(String directoryId, List<Child> allSongs, AtomicInteger pendingRequests, Runnable onComplete) {
|
||||
pendingRequests.incrementAndGet();
|
||||
|
||||
directoryRepository.getMusicDirectory(directoryId).observe(getViewLifecycleOwner(), directory -> {
|
||||
if (directory != null && directory.getChildren() != null) {
|
||||
for (Child child : directory.getChildren()) {
|
||||
if (child.isDir()) {
|
||||
// It's a subdirectory, recurse into it
|
||||
collectSongsFromDirectory(child.getId(), allSongs, pendingRequests, onComplete);
|
||||
} else if (!child.isVideo()) {
|
||||
// It's a song, add it to the list
|
||||
synchronized (allSongs) {
|
||||
allSongs.add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decrement pending requests and check if we're done
|
||||
if (pendingRequests.decrementAndGet() == 0) {
|
||||
onComplete.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,11 @@ import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.session.MediaBrowser;
|
||||
import androidx.media3.session.SessionToken;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import androidx.recyclerview.widget.GridLayoutManager;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
|
||||
@@ -31,6 +35,8 @@ import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.LibraryViewModel;
|
||||
import com.google.android.material.appbar.MaterialToolbar;
|
||||
import com.cappielloantonio.tempo.service.MediaService;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -49,6 +55,7 @@ public class LibraryFragment extends Fragment implements ClickCallback {
|
||||
private PlaylistHorizontalAdapter playlistHorizontalAdapter;
|
||||
|
||||
private MaterialToolbar materialToolbar;
|
||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
@@ -79,6 +86,7 @@ public class LibraryFragment extends Fragment implements ClickCallback {
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
initializeMediaBrowser();
|
||||
activity.setBottomNavigationBarVisibility(true);
|
||||
}
|
||||
|
||||
@@ -292,4 +300,8 @@ public class LibraryFragment extends Fragment implements ClickCallback {
|
||||
public void onMusicFolderClick(Bundle bundle) {
|
||||
Navigation.findNavController(requireView()).navigate(R.id.indexFragment, bundle);
|
||||
}
|
||||
|
||||
private void initializeMediaBrowser() {
|
||||
mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@ public class PlayerBottomSheetFragment extends Fragment {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void setMediaControllerUI(MediaBrowser mediaBrowser) {
|
||||
if (mediaBrowser.getMediaMetadata().extras != null) {
|
||||
switch (mediaBrowser.getMediaMetadata().extras.getString("type", Constants.MEDIA_TYPE_MUSIC)) {
|
||||
|
||||
@@ -13,9 +13,10 @@ import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.RatingBar;
|
||||
import android.widget.TextView;
|
||||
import android.widget.ToggleButton;
|
||||
import android.widget.RatingBar;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
@@ -41,12 +42,14 @@ import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
|
||||
import com.cappielloantonio.tempo.ui.dialog.TrackInfoDialog;
|
||||
import com.cappielloantonio.tempo.ui.fragment.pager.PlayerControllerHorizontalPager;
|
||||
import com.cappielloantonio.tempo.util.AssetLinkUtil;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
|
||||
import com.cappielloantonio.tempo.viewmodel.RatingViewModel;
|
||||
import com.google.android.material.chip.Chip;
|
||||
import com.google.android.material.chip.ChipGroup;
|
||||
import com.google.android.material.elevation.SurfaceColors;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
@@ -76,6 +79,10 @@ public class PlayerControllerFragment extends Fragment {
|
||||
private ImageButton playerTrackInfo;
|
||||
private LinearLayout ratingContainer;
|
||||
private ImageButton equalizerButton;
|
||||
private ChipGroup assetLinkChipGroup;
|
||||
private Chip playerSongLinkChip;
|
||||
private Chip playerAlbumLinkChip;
|
||||
private Chip playerArtistLinkChip;
|
||||
|
||||
private MainActivity activity;
|
||||
private PlayerBottomSheetViewModel playerBottomSheetViewModel;
|
||||
@@ -139,6 +146,10 @@ public class PlayerControllerFragment extends Fragment {
|
||||
songRatingBar = bind.getRoot().findViewById(R.id.song_rating_bar);
|
||||
ratingContainer = bind.getRoot().findViewById(R.id.rating_container);
|
||||
equalizerButton = bind.getRoot().findViewById(R.id.player_open_equalizer_button);
|
||||
assetLinkChipGroup = bind.getRoot().findViewById(R.id.asset_link_chip_group);
|
||||
playerSongLinkChip = bind.getRoot().findViewById(R.id.asset_link_song_chip);
|
||||
playerAlbumLinkChip = bind.getRoot().findViewById(R.id.asset_link_album_chip);
|
||||
playerArtistLinkChip = bind.getRoot().findViewById(R.id.asset_link_artist_chip);
|
||||
checkAndSetRatingContainerVisibility();
|
||||
}
|
||||
|
||||
@@ -219,6 +230,8 @@ public class PlayerControllerFragment extends Fragment {
|
||||
|| mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) && mediaMetadata.extras.getString("uri") != null
|
||||
? View.VISIBLE
|
||||
: View.GONE);
|
||||
|
||||
updateAssetLinkChips(mediaMetadata);
|
||||
}
|
||||
|
||||
private void setMediaInfo(MediaMetadata mediaMetadata) {
|
||||
@@ -259,6 +272,110 @@ public class PlayerControllerFragment extends Fragment {
|
||||
});
|
||||
}
|
||||
|
||||
private void updateAssetLinkChips(MediaMetadata mediaMetadata) {
|
||||
if (assetLinkChipGroup == null) return;
|
||||
String mediaType = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type", Constants.MEDIA_TYPE_MUSIC) : Constants.MEDIA_TYPE_MUSIC;
|
||||
if (!Constants.MEDIA_TYPE_MUSIC.equals(mediaType)) {
|
||||
clearAssetLinkChip(playerSongLinkChip);
|
||||
clearAssetLinkChip(playerAlbumLinkChip);
|
||||
clearAssetLinkChip(playerArtistLinkChip);
|
||||
syncAssetLinkGroupVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
String songId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("id") : null;
|
||||
String albumId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("albumId") : null;
|
||||
String artistId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("artistId") : null;
|
||||
|
||||
AssetLinkUtil.AssetLink songLink = bindAssetLinkChip(playerSongLinkChip, AssetLinkUtil.TYPE_SONG, songId);
|
||||
AssetLinkUtil.AssetLink albumLink = bindAssetLinkChip(playerAlbumLinkChip, AssetLinkUtil.TYPE_ALBUM, albumId);
|
||||
AssetLinkUtil.AssetLink artistLink = bindAssetLinkChip(playerArtistLinkChip, AssetLinkUtil.TYPE_ARTIST, artistId);
|
||||
bindAssetLinkView(playerMediaTitleLabel, songLink);
|
||||
bindAssetLinkView(playerArtistNameLabel, artistLink != null ? artistLink : songLink);
|
||||
bindAssetLinkView(playerMediaCoverViewPager, songLink);
|
||||
syncAssetLinkGroupVisibility();
|
||||
}
|
||||
|
||||
private AssetLinkUtil.AssetLink bindAssetLinkChip(Chip chip, String type, String id) {
|
||||
if (chip == null) return null;
|
||||
if (TextUtils.isEmpty(id)) {
|
||||
clearAssetLinkChip(chip);
|
||||
return null;
|
||||
}
|
||||
|
||||
String label = getString(AssetLinkUtil.getLabelRes(type));
|
||||
AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.buildAssetLink(type, id);
|
||||
if (assetLink == null) {
|
||||
clearAssetLinkChip(chip);
|
||||
return null;
|
||||
}
|
||||
|
||||
chip.setText(getString(R.string.asset_link_chip_text, label, assetLink.id));
|
||||
chip.setVisibility(View.VISIBLE);
|
||||
|
||||
chip.setOnClickListener(v -> {
|
||||
if (assetLink != null) {
|
||||
activity.openAssetLink(assetLink);
|
||||
}
|
||||
});
|
||||
|
||||
chip.setOnLongClickListener(v -> {
|
||||
if (assetLink != null) {
|
||||
AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
|
||||
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, id), Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return assetLink;
|
||||
}
|
||||
|
||||
private void clearAssetLinkChip(Chip chip) {
|
||||
if (chip == null) return;
|
||||
chip.setVisibility(View.GONE);
|
||||
chip.setText("");
|
||||
chip.setOnClickListener(null);
|
||||
chip.setOnLongClickListener(null);
|
||||
}
|
||||
|
||||
private void bindAssetLinkView(View view, AssetLinkUtil.AssetLink assetLink) {
|
||||
if (view == null) return;
|
||||
if (assetLink == null) {
|
||||
AssetLinkUtil.clearLinkAppearance(view);
|
||||
view.setOnClickListener(null);
|
||||
view.setOnLongClickListener(null);
|
||||
view.setClickable(false);
|
||||
view.setLongClickable(false);
|
||||
return;
|
||||
}
|
||||
|
||||
view.setClickable(true);
|
||||
view.setLongClickable(true);
|
||||
AssetLinkUtil.applyLinkAppearance(view);
|
||||
view.setOnClickListener(v -> {
|
||||
boolean collapse = !AssetLinkUtil.TYPE_SONG.equals(assetLink.type);
|
||||
activity.openAssetLink(assetLink, collapse);
|
||||
});
|
||||
view.setOnLongClickListener(v -> {
|
||||
AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
|
||||
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, assetLink.id), Toast.LENGTH_SHORT).show();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void syncAssetLinkGroupVisibility() {
|
||||
if (assetLinkChipGroup == null) return;
|
||||
boolean hasVisible = false;
|
||||
for (int i = 0; i < assetLinkChipGroup.getChildCount(); i++) {
|
||||
View child = assetLinkChipGroup.getChildAt(i);
|
||||
if (child.getVisibility() == View.VISIBLE) {
|
||||
hasVisible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assetLinkChipGroup.setVisibility(hasVisible ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
private void setMediaControllerUI(MediaBrowser mediaBrowser) {
|
||||
initPlaybackSpeedButton(mediaBrowser);
|
||||
|
||||
@@ -548,4 +665,4 @@ public class PlayerControllerFragment extends Fragment {
|
||||
isServiceBound = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,20 @@ package com.cappielloantonio.tempo.ui.fragment;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.session.MediaBrowser;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.session.SessionToken;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
@@ -18,11 +23,17 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerQueueBinding;
|
||||
import com.cappielloantonio.tempo.interfaces.ClickCallback;
|
||||
import com.cappielloantonio.tempo.service.DownloaderManager;
|
||||
import com.cappielloantonio.tempo.service.MediaManager;
|
||||
import com.cappielloantonio.tempo.service.MediaService;
|
||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||
import com.cappielloantonio.tempo.subsonic.models.PlayQueue;
|
||||
import com.cappielloantonio.tempo.ui.adapter.PlayerSongQueueAdapter;
|
||||
import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
|
||||
import com.cappielloantonio.tempo.util.Constants;
|
||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
@@ -30,6 +41,7 @@ import com.google.common.util.concurrent.MoreExecutors;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@UnstableApi
|
||||
@@ -38,6 +50,18 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||
|
||||
private InnerFragmentPlayerQueueBinding bind;
|
||||
|
||||
private com.google.android.material.floatingactionbutton.FloatingActionButton fabMenuToggle;
|
||||
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabClearQueue;
|
||||
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabShuffleQueue;
|
||||
|
||||
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabSaveToPlaylist;
|
||||
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabDownloadAll;
|
||||
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabLoadQueue;
|
||||
|
||||
private boolean isMenuOpen = false;
|
||||
private final int ANIMATION_DURATION = 250;
|
||||
private final float FAB_VERTICAL_SPACING_DP = 70f;
|
||||
|
||||
private PlayerBottomSheetViewModel playerBottomSheetViewModel;
|
||||
private PlaybackViewModel playbackViewModel;
|
||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||
@@ -52,6 +76,27 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||
playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
|
||||
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
|
||||
|
||||
fabMenuToggle = bind.fabMenuToggle;
|
||||
fabClearQueue = bind.fabClearQueue;
|
||||
fabShuffleQueue = bind.fabShuffleQueue;
|
||||
|
||||
fabSaveToPlaylist = bind.fabSaveToPlaylist;
|
||||
fabDownloadAll = bind.fabDownloadAll;
|
||||
fabLoadQueue = bind.fabLoadQueue;
|
||||
|
||||
fabMenuToggle.setOnClickListener(v -> toggleFabMenu());
|
||||
fabClearQueue.setOnClickListener(v -> handleClearQueueClick());
|
||||
fabShuffleQueue.setOnClickListener(v -> handleShuffleQueueClick());
|
||||
|
||||
fabSaveToPlaylist.setOnClickListener(v -> handleSaveToPlaylistClick());
|
||||
fabDownloadAll.setOnClickListener(v -> handleDownloadAllClick());
|
||||
fabLoadQueue.setOnClickListener(v -> handleLoadQueueClick());
|
||||
|
||||
// Hide Load Queue FAB if sync is disabled
|
||||
if (!Preferences.isSyncronizationEnabled()) {
|
||||
fabLoadQueue.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
initQueueRecyclerView();
|
||||
|
||||
return view;
|
||||
@@ -61,8 +106,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
initializeBrowser();
|
||||
bindMediaController();
|
||||
|
||||
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
|
||||
observePlayback();
|
||||
}
|
||||
@@ -72,6 +115,16 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||
super.onResume();
|
||||
setMediaBrowserListenableFuture();
|
||||
updateNowPlayingItem();
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
long position = mediaBrowserListenableFuture.get().getCurrentMediaItemIndex();
|
||||
requireActivity().runOnUiThread(() -> {
|
||||
bind.playerQueueRecyclerView.scrollToPosition((int) position);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Log.e("PlayerQueueFragment", "Failed to get mediaBrowserListenableFuture in onResume", e);
|
||||
}
|
||||
}, MoreExecutors.directExecutor());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -94,18 +147,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||
MediaBrowser.releaseFuture(mediaBrowserListenableFuture);
|
||||
}
|
||||
|
||||
private void bindMediaController() {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
|
||||
initShuffleButton(mediaBrowser);
|
||||
initCleanButton(mediaBrowser);
|
||||
} catch (Exception exception) {
|
||||
exception.printStackTrace();
|
||||
}
|
||||
}, MoreExecutors.directExecutor());
|
||||
}
|
||||
|
||||
private void setMediaBrowserListenableFuture() {
|
||||
playerSongQueueAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
|
||||
}
|
||||
@@ -138,18 +179,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||
|
||||
fromPosition = viewHolder.getBindingAdapterPosition();
|
||||
toPosition = target.getBindingAdapterPosition();
|
||||
|
||||
/*
|
||||
* Per spostare un elemento nella coda devo:
|
||||
* - Spostare graficamente la traccia da una posizione all'altra con Collections.swap()
|
||||
* - Spostare nel db la traccia, tramite QueueRepository
|
||||
* - Notificare il Service dell'avvenuto spostamento con MusicPlayerRemote.moveSong()
|
||||
*
|
||||
* In onMove prendo la posizione di inizio e fine, ma solo al rilascio dell'elemento procedo allo spostamento
|
||||
* In questo modo evito che ad ogni cambio di posizione vada a riscrivere nel db
|
||||
* Al rilascio dell'elemento chiamo il metodo clearView()
|
||||
*/
|
||||
|
||||
Collections.swap(playerSongQueueAdapter.getItems(), fromPosition, toPosition);
|
||||
recyclerView.getAdapter().notifyItemMoved(fromPosition, toPosition);
|
||||
|
||||
@@ -177,46 +206,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||
}).attachToRecyclerView(bind.playerQueueRecyclerView);
|
||||
}
|
||||
|
||||
private void initShuffleButton(MediaBrowser mediaBrowser) {
|
||||
bind.playerShuffleQueueFab.setOnClickListener(view -> {
|
||||
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
|
||||
int endPosition = playerSongQueueAdapter.getItems().size() - 1;
|
||||
|
||||
if (startPosition < endPosition) {
|
||||
ArrayList<Integer> pool = new ArrayList<>();
|
||||
|
||||
for (int i = startPosition; i <= endPosition; i++) {
|
||||
pool.add(i);
|
||||
}
|
||||
|
||||
while (pool.size() >= 2) {
|
||||
int fromPosition = (int) (Math.random() * (pool.size()));
|
||||
int positionA = pool.get(fromPosition);
|
||||
pool.remove(fromPosition);
|
||||
|
||||
int toPosition = (int) (Math.random() * (pool.size()));
|
||||
int positionB = pool.get(toPosition);
|
||||
pool.remove(toPosition);
|
||||
|
||||
Collections.swap(playerSongQueueAdapter.getItems(), positionA, positionB);
|
||||
bind.playerQueueRecyclerView.getAdapter().notifyItemMoved(positionA, positionB);
|
||||
}
|
||||
|
||||
MediaManager.shuffle(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initCleanButton(MediaBrowser mediaBrowser) {
|
||||
bind.playerCleanQueueButton.setOnClickListener(view -> {
|
||||
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
|
||||
int endPosition = playerSongQueueAdapter.getItems().size();
|
||||
|
||||
MediaManager.removeRange(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
|
||||
bind.playerQueueRecyclerView.getAdapter().notifyItemRangeRemoved(startPosition, endPosition);
|
||||
});
|
||||
}
|
||||
|
||||
private void updateNowPlayingItem() {
|
||||
playerSongQueueAdapter.notifyDataSetChanged();
|
||||
}
|
||||
@@ -248,4 +237,216 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
|
||||
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the visibility and animates all six secondary FABs.
|
||||
*/
|
||||
private void toggleFabMenu() {
|
||||
if (isMenuOpen) {
|
||||
// CLOSE MENU (Reverse order for visual effect)
|
||||
if (Preferences.isSyncronizationEnabled()) {
|
||||
closeFab(fabLoadQueue, 4);
|
||||
}
|
||||
closeFab(fabSaveToPlaylist, 3);
|
||||
closeFab(fabClearQueue, 2);
|
||||
closeFab(fabDownloadAll, 1);
|
||||
closeFab(fabShuffleQueue, 0);
|
||||
|
||||
fabMenuToggle.animate().rotation(0f).setDuration(ANIMATION_DURATION).start();
|
||||
} else {
|
||||
// OPEN MENU (lowest index at bottom)
|
||||
openFab(fabShuffleQueue, 0);
|
||||
openFab(fabDownloadAll, 1);
|
||||
openFab(fabClearQueue, 2);
|
||||
openFab(fabSaveToPlaylist, 3);
|
||||
if (Preferences.isSyncronizationEnabled()) {
|
||||
openFab(fabLoadQueue, 4);
|
||||
}
|
||||
fabMenuToggle.animate().rotation(45f).setDuration(ANIMATION_DURATION).start();
|
||||
}
|
||||
isMenuOpen = !isMenuOpen;
|
||||
}
|
||||
|
||||
private void openFab(View fab, int index) {
|
||||
final float displacement = getResources().getDisplayMetrics().density * (FAB_VERTICAL_SPACING_DP * (index + 1));
|
||||
|
||||
fab.setVisibility(View.VISIBLE);
|
||||
fab.setAlpha(0f);
|
||||
fab.setTranslationY(displacement); // Start at the hidden (closed) position
|
||||
|
||||
fab.animate()
|
||||
.translationY(0f)
|
||||
.alpha(1f)
|
||||
.setDuration(ANIMATION_DURATION)
|
||||
.start();
|
||||
}
|
||||
|
||||
private void closeFab(View fab, int index) {
|
||||
final float displacement = getResources().getDisplayMetrics().density * (FAB_VERTICAL_SPACING_DP * (index + 1));
|
||||
|
||||
fab.animate()
|
||||
.translationY(displacement)
|
||||
.alpha(0f)
|
||||
.setDuration(ANIMATION_DURATION)
|
||||
.withEndAction(() -> fab.setVisibility(View.GONE))
|
||||
.start();
|
||||
}
|
||||
|
||||
private void handleShuffleQueueClick() {
|
||||
Log.d(TAG, "Shuffle Queue Clicked!");
|
||||
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
|
||||
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
|
||||
int endPosition = playerSongQueueAdapter.getItems().size() - 1;
|
||||
|
||||
if (startPosition < endPosition) {
|
||||
ArrayList<Integer> pool = new ArrayList<>();
|
||||
|
||||
for (int i = startPosition; i <= endPosition; i++) {
|
||||
pool.add(i);
|
||||
}
|
||||
|
||||
while (pool.size() >= 2) {
|
||||
int fromPosition = (int) (Math.random() * (pool.size()));
|
||||
int positionA = pool.get(fromPosition);
|
||||
pool.remove(fromPosition);
|
||||
|
||||
int toPosition = (int) (Math.random() * (pool.size()));
|
||||
int positionB = pool.get(toPosition);
|
||||
pool.remove(toPosition);
|
||||
|
||||
Collections.swap(playerSongQueueAdapter.getItems(), positionA, positionB);
|
||||
bind.playerQueueRecyclerView.getAdapter().notifyItemMoved(positionA, positionB);
|
||||
}
|
||||
|
||||
MediaManager.shuffle(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error shuffling queue", e);
|
||||
}
|
||||
|
||||
toggleFabMenu();
|
||||
}, MoreExecutors.directExecutor());
|
||||
}
|
||||
|
||||
private void handleClearQueueClick() {
|
||||
Log.d(TAG, "Clear Queue Clicked!");
|
||||
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
try {
|
||||
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
|
||||
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
|
||||
int endPosition = playerSongQueueAdapter.getItems().size();
|
||||
|
||||
MediaManager.removeRange(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
|
||||
bind.playerQueueRecyclerView.getAdapter().notifyItemRangeRemoved(startPosition, endPosition - startPosition);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error clearing queue", e);
|
||||
}
|
||||
|
||||
toggleFabMenu();
|
||||
}, MoreExecutors.directExecutor());
|
||||
}
|
||||
|
||||
private void handleSaveToPlaylistClick() {
|
||||
Log.d(TAG, "Save to Playlist Clicked!");
|
||||
|
||||
List<Child> queueSongs = playerSongQueueAdapter.getItems();
|
||||
|
||||
if (queueSongs == null || queueSongs.isEmpty()) {
|
||||
Toast.makeText(requireContext(), "Queue is empty", Toast.LENGTH_SHORT).show();
|
||||
toggleFabMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(queueSongs));
|
||||
|
||||
PlaylistChooserDialog dialog = new PlaylistChooserDialog();
|
||||
dialog.setArguments(bundle);
|
||||
dialog.show(requireActivity().getSupportFragmentManager(), null);
|
||||
|
||||
toggleFabMenu();
|
||||
}
|
||||
|
||||
private void handleDownloadAllClick() {
|
||||
Log.d(TAG, "Download All Clicked!");
|
||||
|
||||
List<Child> queueSongs = playerSongQueueAdapter.getItems();
|
||||
|
||||
if (queueSongs == null || queueSongs.isEmpty()) {
|
||||
Toast.makeText(requireContext(), "Queue is empty", Toast.LENGTH_SHORT).show();
|
||||
toggleFabMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
List<MediaItem> mediaItemsToDownload = MappingUtil.mapMediaItems(queueSongs);
|
||||
|
||||
List<com.cappielloantonio.tempo.model.Download> downloadModels = new ArrayList<>();
|
||||
|
||||
for (Child child : queueSongs) {
|
||||
com.cappielloantonio.tempo.model.Download downloadModel =
|
||||
new com.cappielloantonio.tempo.model.Download(child);
|
||||
downloadModel.setArtist(child.getArtist());
|
||||
downloadModel.setAlbum(child.getAlbum());
|
||||
downloadModel.setCoverArtId(child.getCoverArtId());
|
||||
downloadModels.add(downloadModel);
|
||||
}
|
||||
|
||||
DownloaderManager downloaderManager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
|
||||
if (downloaderManager != null) {
|
||||
downloaderManager.download(mediaItemsToDownload, downloadModels);
|
||||
Toast.makeText(requireContext(), "Starting download of " + queueSongs.size() + " songs in the background.", Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
Log.e(TAG, "DownloaderManager not initialized. Check DownloadUtil.");
|
||||
Toast.makeText(requireContext(), "Download service unavailable.", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
toggleFabMenu();
|
||||
}
|
||||
|
||||
private void handleLoadQueueClick() {
|
||||
Log.d(TAG, "Load Queue Clicked!");
|
||||
if (!Preferences.isSyncronizationEnabled()) {
|
||||
toggleFabMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBottomSheetViewModel playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
|
||||
|
||||
playerBottomSheetViewModel.getPlayQueue().observe(getViewLifecycleOwner(), new Observer<PlayQueue>() {
|
||||
@Override
|
||||
public void onChanged(PlayQueue playQueue) {
|
||||
playerBottomSheetViewModel.getPlayQueue().removeObserver(this);
|
||||
|
||||
if (playQueue != null && playQueue.getEntries() != null && !playQueue.getEntries().isEmpty()) {
|
||||
int currentIndex = 0;
|
||||
for (int i = 0; i < playQueue.getEntries().size(); i++) {
|
||||
if (playQueue.getEntries().get(i).getId().equals(playQueue.getCurrent())) {
|
||||
currentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
MediaManager.startQueue(mediaBrowserListenableFuture, playQueue.getEntries(), currentIndex);
|
||||
|
||||
Toast.makeText(requireContext(), "Queue loaded", Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
Toast.makeText(requireContext(), "No saved queue found", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
toggleFabMenu();
|
||||
}
|
||||
});
|
||||
|
||||
new Handler().postDelayed(() -> {
|
||||
if (isMenuOpen) {
|
||||
toggleFabMenu();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||