diff --git a/.github/workflows/github_release.yml b/.github/workflows/github_release.yml
index 9bc2d851..d026f23a 100644
--- a/.github/workflows/github_release.yml
+++ b/.github/workflows/github_release.yml
@@ -3,7 +3,7 @@ name: Github Release Workflow
on:
push:
tags:
- - '[0-9]+.[0-9]+.[0-9]+'
+ - 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
build:
@@ -35,12 +35,18 @@ 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 APKs
id: build
- run: bash ./gradlew assembleTempoRelease
+ run: |
+ # Build release variants
+ bash ./gradlew assembleTempoRelease
+ bash ./gradlew assembleNotquitemyRelease
+ # Build debug variants
+ bash ./gradlew assembleTempoDebug
+ bash ./gradlew assembleNotquitemyDebug
- - name: Sign APK
- id: sign_apk
+ - name: Sign Tempo Release APKs
+ id: sign_tempo_release
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/tempo/release
@@ -51,11 +57,17 @@ jobs:
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
- - name: Make artifact
- uses: actions/upload-artifact@v4
+ - name: Sign NotQuiteMy Release APKs
+ id: sign_notquitemy_release
+ uses: r0adkll/sign-android-release@v1
with:
- name: app-release-signed
- path: ${{steps.sign_apk.outputs.signedReleaseFile}}
+ releaseDirectory: app/build/outputs/apk/notquitemy/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: Create Release
id: create_release
@@ -67,12 +79,40 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
- - name: Upload APK
+ - name: Upload Release APKs
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
- asset_path: ${{steps.sign_apk.outputs.signedReleaseFile}}
+ asset_path: ${{steps.sign_tempo_release.outputs.signedReleaseFile}}
asset_name: app-tempo-release.apk
- asset_content_type: application/zip
+ asset_content_type: application/vnd.android.package-archive
+
+ - name: Upload NotQuiteMy Release APK
+ uses: actions/upload-release-asset@v1
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ with:
+ upload_url: ${{ steps.create_release.outputs.upload_url }}
+ asset_path: ${{steps.sign_notquitemy_release.outputs.signedReleaseFile}}
+ asset_name: app-notquitemy-release.apk
+ asset_content_type: application/vnd.android.package-archive
+
+ - name: Upload Debug APKs as artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: debug-apks
+ path: |
+ app/build/outputs/apk/tempo/debug/
+ app/build/outputs/apk/notquitemy/debug/
+ retention-days: 30
+
+ - name: Upload Release APKs as artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: release-apks
+ path: |
+ ${{steps.sign_tempo_release.outputs.signedReleaseFile}}
+ ${{steps.sign_notquitemy_release.outputs.signedReleaseFile}}
+ retention-days: 30
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 1047677c..48ad5d8f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,4 +17,5 @@
.vscode/settings.json
# release / debug files
tempus-release-key.jks
-app/tempo/
\ No newline at end of file
+app/tempo/
+app/notquitemy/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c443a37..76c83d17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,92 @@
***This log is for this fork to detail updates since 3.9.0 from the main repo.***
+## [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
+* chore: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/84
+* chore: Update RU locale by @ArchiDevil in https://github.com/eddyizm/tempo/pull/87
+* chore: Update Korean translations by @kongwoojin in https://github.com/eddyizm/tempo/pull/97
+* fix: only plays the first song on an album by @eddyizm in https://github.com/eddyizm/tempo/pull/81
+* fix: handle null and not crash when disconnecting chromecast by @eddyizm in https://github.com/eddyizm/tempo/pull/81
+* feat: Built-in audio equalizer by @jaime-grj in https://github.com/eddyizm/tempo/pull/94
+* fix: Resolve playback issues with live radio MPEG & HLS streams by @jaime-grj in https://github.com/eddyizm/tempo/pull/89
+* chore: Updates to polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/105
+* feat: added 32bit build and debug build for testing. Removed unused f… by @eddyizm in https://github.com/eddyizm/tempo/pull/108
+* feat: Mark currently playing song with play/pause button by @jaime-grj in https://github.com/eddyizm/tempo/pull/107
+* fix: add listener to track playlist click/change by @eddyizm in https://github.com/eddyizm/tempo/pull/113
+* feat: Tap anywhere on the song item to toggle playback by @jaime-grj in https://github.com/eddyizm/tempo/pull/112
+
+## New Contributors
+* @ArchiDevil made their first contribution in https://github.com/eddyizm/tempo/pull/87
+* @kongwoojin made their first contribution in https://github.com/eddyizm/tempo/pull/97
+
+**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.14.8...v3.15.0
+
+
+## [3.14.8](https://github.com/eddyizm/tempo/releases/tag/v3.14.8) (2025-08-30)
+## What's Changed
+* fix: Use correct SearchView widget to avoid crash in AlbumListPageFragment by @jaime-grj in https://github.com/eddyizm/tempo/pull/76
+* chore(i18n): Update Spanish (es-ES) and English translations by @jaime-grj in https://github.com/eddyizm/tempo/pull/77
+* style: Center subtitle text in empty_download_layout in fragment_download.xml when there is more than one line by @jaime-grj in https://github.com/eddyizm/tempo/pull/78
+* fix: Disable "sync starred tracks/albums" switches when Cancel is clicked in warning dialog, use proper view for "Sync starred albums" dialog by @jaime-grj in https://github.com/eddyizm/tempo/pull/79
+* bug fixes, chores, docs v3.14.8 by @eddyizm in https://github.com/eddyizm/tempo/pull/80
+
+
+**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.14.1...v3.14.8
+
## [3.14.1](https://github.com/eddyizm/tempo/releases/tag/v3.14.1) (2025-08-30)
## What's Changed
* feat: rating dialog added to album page by @eddyizm in https://github.com/eddyizm/tempo/pull/52
diff --git a/README.md b/README.md
index 01e81112..07bdc1ac 100644
--- a/README.md
+++ b/README.md
@@ -24,9 +24,22 @@ Tempo does not rely on magic algorithms to decide what you should listen to. Ins
## Fork
+sha256 signing key fingerprint
+`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.
-Moved details to [CHANGELOG.md](https://github.com/eddyizm/tempo/blob/main/CHANGELOG.md)
+### Releases
+
+Please note the two variants in the release assets include release/debug and 32/64 bit flavors.
+
+`app-tempo` <- The github release with all the android auto/chromecast features
+
+`app-notquitemy*` <- The f-droid release that goes without any of the google stuff. It was last released at 3.8.1 from the original repo. Since I don't have access to that original repo, I am releasing the apk's here on github.
+
+As mentioned above, I am working towards a rebrand to get into app stores with a new name an icon.
+
+Moved details to [CHANGELOG.md](CHANGELOG.md)
Fork [**sponsorship here**](https://ko-fi.com/eddyizm).
@@ -46,13 +59,11 @@ Fork [**sponsorship here**](https://ko-fi.com/eddyizm).
- **Podcasts and Radio**: If your Subsonic server supports it, listen to podcasts and radio shows directly within Tempo, 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.
+- **Multiple Libraries**: Tempo handles multi-library setups gracefully. They are displayed as Library folders.
-## Sponsors
+## Credits
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.
-
-
## Screenshot
@@ -87,6 +98,16 @@ Tempo is an open-source project developed and maintained solely by me. I would l
+## 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.
+
## 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.
diff --git a/USAGE.md b/USAGE.md
index 7cf5c25b..9ce9496b 100644
--- a/USAGE.md
+++ b/USAGE.md
@@ -57,10 +57,30 @@ This app works with any service that implements the Subsonic API, including:
## Main Features
### Library View
-**TODO**
+
+**Multi-library**
+
+Tempo 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 Tempo app.
### Now Playing Screen
-**TODO**
+
+On the main player control screen, tapping on the artwork will reveal a small collection of 4 buttons/icons.
+
+
+
+
+*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
+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)
+
## Navigation
diff --git a/app/build.gradle b/app/build.gradle
index 71530899..6d9e1884 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -10,9 +10,8 @@ android {
minSdkVersion 24
targetSdk 35
- versionCode 31
- versionName '3.14.8'
-
+ versionCode 36
+ versionName '3.17.14'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
javaCompileOptions {
@@ -23,8 +22,21 @@ android {
]
}
}
+
}
+ splits {
+ abi {
+ enable true
+ reset()
+ //noinspection ChromeOsAbiSupport
+ include 'armeabi-v7a', 'arm64-v8a'
+ universalApk false
+ }
+ }
+
+
+
flavorDimensions += "default"
productFlavors {
@@ -38,10 +50,6 @@ android {
applicationId "com.cappielloantonio.notquitemy.tempo"
}
- play {
- dimension = "default"
- applicationId "com.cappielloantonio.play.tempo"
- }
}
buildTypes {
@@ -51,6 +59,11 @@ android {
debuggable false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
+
+ debug {
+ applicationIdSuffix ".debug"
+ debuggable true
+ }
}
compileOptions {
@@ -98,7 +111,7 @@ dependencies {
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'
+
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
annotationProcessor 'androidx.room:room-compiler:2.6.1'
@@ -112,4 +125,4 @@ java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
-}
\ No newline at end of file
+}
diff --git a/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json
new file mode 100644
index 00000000..77974597
--- /dev/null
+++ b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json
@@ -0,0 +1,1151 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 12,
+ "identityHash": "2d26471ae15a1cdaf996261b72f81613",
+ "entities": [
+ {
+ "tableName": "queue",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `track_order` INTEGER NOT NULL, `last_play` INTEGER NOT NULL, `playing_changed` INTEGER NOT NULL, `stream_id` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`track_order`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackOrder",
+ "columnName": "track_order",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastPlay",
+ "columnName": "last_play",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playingChanged",
+ "columnName": "playing_changed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "streamId",
+ "columnName": "stream_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parent_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isDir",
+ "columnName": "is_dir",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "track",
+ "columnName": "track",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "cover_art_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "size",
+ "columnName": "size",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "contentType",
+ "columnName": "content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "suffix",
+ "columnName": "suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedContentType",
+ "columnName": "transcoding_content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedSuffix",
+ "columnName": "transcoded_suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "samplingRate",
+ "columnName": "sampling_rate",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bitDepth",
+ "columnName": "bit_depth",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isVideo",
+ "columnName": "is_video",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userRating",
+ "columnName": "user_rating",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "averageRating",
+ "columnName": "average_rating",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "playCount",
+ "columnName": "play_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "created",
+ "columnName": "created",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "starred",
+ "columnName": "starred",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bookmarkPosition",
+ "columnName": "bookmark_position",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalWidth",
+ "columnName": "original_width",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalHeight",
+ "columnName": "original_height",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "track_order"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "server",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_name` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `address` TEXT NOT NULL, `local_address` TEXT, `timestamp` INTEGER NOT NULL, `low_security` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverName",
+ "columnName": "server_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "username",
+ "columnName": "username",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "password",
+ "columnName": "password",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "address",
+ "columnName": "address",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "localAddress",
+ "columnName": "local_address",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isLowSecurity",
+ "columnName": "low_security",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "false"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "recent_search",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`search` TEXT NOT NULL, PRIMARY KEY(`search`))",
+ "fields": [
+ {
+ "fieldPath": "search",
+ "columnName": "search",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "search"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "download",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlist_id` TEXT, `playlist_name` TEXT, `download_state` INTEGER NOT NULL DEFAULT 1, `download_uri` TEXT DEFAULT '', `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "playlistName",
+ "columnName": "playlist_name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "downloadState",
+ "columnName": "download_state",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "downloadUri",
+ "columnName": "download_uri",
+ "affinity": "TEXT",
+ "notNull": false,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parent_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isDir",
+ "columnName": "is_dir",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "track",
+ "columnName": "track",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "cover_art_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "size",
+ "columnName": "size",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "contentType",
+ "columnName": "content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "suffix",
+ "columnName": "suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedContentType",
+ "columnName": "transcoding_content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedSuffix",
+ "columnName": "transcoded_suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "samplingRate",
+ "columnName": "sampling_rate",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bitDepth",
+ "columnName": "bit_depth",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isVideo",
+ "columnName": "is_video",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userRating",
+ "columnName": "user_rating",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "averageRating",
+ "columnName": "average_rating",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "playCount",
+ "columnName": "play_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "created",
+ "columnName": "created",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "starred",
+ "columnName": "starred",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bookmarkPosition",
+ "columnName": "bookmark_position",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalWidth",
+ "columnName": "original_width",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalHeight",
+ "columnName": "original_height",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "chronology",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `server` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "server",
+ "columnName": "server",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parent_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isDir",
+ "columnName": "is_dir",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "track",
+ "columnName": "track",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "cover_art_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "size",
+ "columnName": "size",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "contentType",
+ "columnName": "content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "suffix",
+ "columnName": "suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedContentType",
+ "columnName": "transcoding_content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedSuffix",
+ "columnName": "transcoded_suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "samplingRate",
+ "columnName": "sampling_rate",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bitDepth",
+ "columnName": "bit_depth",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isVideo",
+ "columnName": "is_video",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userRating",
+ "columnName": "user_rating",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "averageRating",
+ "columnName": "average_rating",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "playCount",
+ "columnName": "play_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "created",
+ "columnName": "created",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "starred",
+ "columnName": "starred",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bookmarkPosition",
+ "columnName": "bookmark_position",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalWidth",
+ "columnName": "original_width",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalHeight",
+ "columnName": "original_height",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "favorite",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `songId` TEXT, `albumId` TEXT, `artistId` TEXT, `toStar` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))",
+ "fields": [
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artistId",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "toStar",
+ "columnName": "toStar",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "timestamp"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "session_media_item",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`index` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, `stream_id` TEXT, `stream_url` TEXT, `timestamp` INTEGER)",
+ "fields": [
+ {
+ "fieldPath": "index",
+ "columnName": "index",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parent_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isDir",
+ "columnName": "is_dir",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "track",
+ "columnName": "track",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "cover_art_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "size",
+ "columnName": "size",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "contentType",
+ "columnName": "content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "suffix",
+ "columnName": "suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedContentType",
+ "columnName": "transcoding_content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedSuffix",
+ "columnName": "transcoded_suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isVideo",
+ "columnName": "is_video",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userRating",
+ "columnName": "user_rating",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "averageRating",
+ "columnName": "average_rating",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "playCount",
+ "columnName": "play_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "created",
+ "columnName": "created",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "starred",
+ "columnName": "starred",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bookmarkPosition",
+ "columnName": "bookmark_position",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalWidth",
+ "columnName": "original_width",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalHeight",
+ "columnName": "original_height",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "streamId",
+ "columnName": "stream_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "streamUrl",
+ "columnName": "stream_url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "index"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "playlist",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `duration` INTEGER NOT NULL, `coverArt` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "coverArt",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "lyrics_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `artist` TEXT, `title` TEXT, `lyrics` TEXT, `structured_lyrics` TEXT, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`song_id`))",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "song_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "lyrics",
+ "columnName": "lyrics",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "structuredLyrics",
+ "columnName": "structured_lyrics",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "song_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2d26471ae15a1cdaf996261b72f81613')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cc9990e7..b8d72d8b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -42,6 +42,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java b/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java
index b19e934f..3a5e98ef 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java
@@ -12,6 +12,7 @@ import com.cappielloantonio.tempo.database.converter.DateConverters;
import com.cappielloantonio.tempo.database.dao.ChronologyDao;
import com.cappielloantonio.tempo.database.dao.DownloadDao;
import com.cappielloantonio.tempo.database.dao.FavoriteDao;
+import com.cappielloantonio.tempo.database.dao.LyricsDao;
import com.cappielloantonio.tempo.database.dao.PlaylistDao;
import com.cappielloantonio.tempo.database.dao.QueueDao;
import com.cappielloantonio.tempo.database.dao.RecentSearchDao;
@@ -20,6 +21,7 @@ import com.cappielloantonio.tempo.database.dao.SessionMediaItemDao;
import com.cappielloantonio.tempo.model.Chronology;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.model.Favorite;
+import com.cappielloantonio.tempo.model.LyricsCache;
import com.cappielloantonio.tempo.model.Queue;
import com.cappielloantonio.tempo.model.RecentSearch;
import com.cappielloantonio.tempo.model.Server;
@@ -28,9 +30,9 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
@UnstableApi
@Database(
- version = 11,
- entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class},
- autoMigrations = {@AutoMigration(from = 10, to = 11)}
+ version = 12,
+ 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)}
)
@TypeConverters({DateConverters.class})
public abstract class AppDatabase extends RoomDatabase {
@@ -62,4 +64,6 @@ public abstract class AppDatabase extends RoomDatabase {
public abstract SessionMediaItemDao sessionMediaItemDao();
public abstract PlaylistDao playlistDao();
+
+ public abstract LyricsDao lyricsDao();
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/database/dao/DownloadDao.java b/app/src/main/java/com/cappielloantonio/tempo/database/dao/DownloadDao.java
index 628e9dd6..a2d49f6b 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/database/dao/DownloadDao.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/database/dao/DownloadDao.java
@@ -15,6 +15,9 @@ public interface DownloadDao {
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
LiveData> getAll();
+ @Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
+ List getAllSync();
+
@Query("SELECT * FROM download WHERE id = :id")
Download getOne(String id);
@@ -30,6 +33,9 @@ public interface DownloadDao {
@Query("DELETE FROM download WHERE id = :id")
void delete(String id);
+ @Query("DELETE FROM download WHERE id IN (:ids)")
+ void deleteByIds(List ids);
+
@Query("DELETE FROM download")
void deleteAll();
}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/database/dao/LyricsDao.java b/app/src/main/java/com/cappielloantonio/tempo/database/dao/LyricsDao.java
new file mode 100644
index 00000000..89d0d585
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/database/dao/LyricsDao.java
@@ -0,0 +1,24 @@
+package com.cappielloantonio.tempo.database.dao;
+
+import androidx.lifecycle.LiveData;
+import androidx.room.Dao;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+
+import com.cappielloantonio.tempo.model.LyricsCache;
+
+@Dao
+public interface LyricsDao {
+ @Query("SELECT * FROM lyrics_cache WHERE song_id = :songId")
+ LyricsCache getOne(String songId);
+
+ @Query("SELECT * FROM lyrics_cache WHERE song_id = :songId")
+ LiveData observeOne(String songId);
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ void insert(LyricsCache lyricsCache);
+
+ @Query("DELETE FROM lyrics_cache WHERE song_id = :songId")
+ void delete(String songId);
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideModule.java b/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideModule.java
index b2fd1a06..ccbffb21 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideModule.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideModule.java
@@ -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());
+ }
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java b/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java
index fe57c163..a6e650e2 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java
@@ -1,6 +1,7 @@
package com.cappielloantonio.tempo.glide;
import android.content.Context;
+import android.graphics.Bitmap;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.util.Log;
@@ -16,6 +17,7 @@ import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
import com.bumptech.glide.request.RequestOptions;
+import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.signature.ObjectKey;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.R;
@@ -109,9 +111,21 @@ public class CustomGlideRequest {
return uri.toString();
}
+ public static void loadAlbumArtBitmap(Context context,
+ String coverId,
+ int size,
+ CustomTarget target) {
+ String url = createUrl(coverId, size);
+ Glide.with(context)
+ .asBitmap()
+ .load(url)
+ .apply(createRequestOptions(context, coverId, ResourceType.Album))
+ .into(target);
+ }
+
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);
diff --git a/app/src/main/java/com/cappielloantonio/tempo/glide/IPv6StringLoader.java b/app/src/main/java/com/cappielloantonio/tempo/glide/IPv6StringLoader.java
new file mode 100644
index 00000000..85307ac9
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/glide/IPv6StringLoader.java
@@ -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 {
+ 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 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 {
+ 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 getDataClass() {
+ return InputStream.class;
+ }
+
+ @NonNull
+ @Override
+ public DataSource getDataSource() {
+ return DataSource.REMOTE;
+ }
+ }
+
+ public static class Factory implements ModelLoaderFactory {
+ @NonNull
+ @Override
+ public ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) {
+ return new IPv6StringLoader();
+ }
+
+ @Override
+ public void teardown() {
+ // No-op
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/Chronology.kt b/app/src/main/java/com/cappielloantonio/tempo/model/Chronology.kt
index 18a77bfa..a3b142e2 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/model/Chronology.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/model/Chronology.kt
@@ -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")
diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/Download.kt b/app/src/main/java/com/cappielloantonio/tempo/model/Download.kt
index d5518adc..0c54e1cd 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/model/Download.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/model/Download.kt
@@ -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?,
)
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/LyricsCache.kt b/app/src/main/java/com/cappielloantonio/tempo/model/LyricsCache.kt
new file mode 100644
index 00000000..3c437e2c
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/model/LyricsCache.kt
@@ -0,0 +1,25 @@
+package com.cappielloantonio.tempo.model
+
+import androidx.annotation.Keep
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import kotlin.jvm.JvmOverloads
+
+@Keep
+@Entity(tableName = "lyrics_cache")
+data class LyricsCache @JvmOverloads constructor(
+ @PrimaryKey
+ @ColumnInfo(name = "song_id")
+ var songId: String,
+ @ColumnInfo(name = "artist")
+ var artist: String? = null,
+ @ColumnInfo(name = "title")
+ var title: String? = null,
+ @ColumnInfo(name = "lyrics")
+ var lyrics: String? = null,
+ @ColumnInfo(name = "structured_lyrics")
+ var structuredLyrics: String? = null,
+ @ColumnInfo(name = "updated_at")
+ var updatedAt: Long = System.currentTimeMillis()
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/Queue.kt b/app/src/main/java/com/cappielloantonio/tempo/model/Queue.kt
index ca2300c2..87840178 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/model/Queue.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/model/Queue.kt
@@ -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
diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/SessionMediaItem.kt b/app/src/main/java/com/cappielloantonio/tempo/model/SessionMediaItem.kt
index 90d01f90..60d641ce 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/model/SessionMediaItem.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/model/SessionMediaItem.kt
@@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.model
import android.net.Uri
import android.os.Bundle
import androidx.annotation.Keep
+import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.RequestMetadata
import androidx.media3.common.MediaMetadata
@@ -243,6 +244,13 @@ class SessionMediaItem() {
.setAlbumTitle(album)
.setArtist(artist)
.setArtworkUri(artworkUri)
+ .setUserRating(HeartRating(starred != null))
+ .setSupportedCommands(
+ listOf(
+ Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON,
+ Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
+ )
+ )
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/AlbumRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/AlbumRepository.java
index 6dc8d3e3..bcc358b5 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/repository/AlbumRepository.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/repository/AlbumRepository.java
@@ -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() {
@Override
public void onResponse(@NonNull Call call, @NonNull Response 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 call, @NonNull Throwable t) {
-
+ Log.e("AlbumRepository", "Network Failure on getAlbums: " + t.getMessage());
+ listLiveAlbums.setValue(new ArrayList<>());
}
});
diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java
index f39dbffa..4e06fad7 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java
@@ -2,10 +2,12 @@ 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.subsonic.base.ApiResponse;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
+import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistInfo2;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.IndexID3;
@@ -13,12 +15,92 @@ import com.cappielloantonio.tempo.subsonic.models.IndexID3;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class ArtistRepository {
+ private final AlbumRepository albumRepository;
+
+ public ArtistRepository() {
+ this.albumRepository = new AlbumRepository();
+ }
+
+ public void getArtistAllSongs(String artistId, ArtistSongsCallback callback) {
+ Log.d("ArtistSync", "Getting albums for artist: " + artistId);
+
+ // Get the artist info first, which contains the albums
+ App.getSubsonicClientInstance(false)
+ .getBrowsingClient()
+ .getArtist(artistId)
+ .enqueue(new Callback() {
+ @Override
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
+ if (response.isSuccessful() && response.body() != null &&
+ response.body().getSubsonicResponse().getArtist() != null &&
+ response.body().getSubsonicResponse().getArtist().getAlbums() != null) {
+
+ List albums = response.body().getSubsonicResponse().getArtist().getAlbums();
+ Log.d("ArtistSync", "Got albums directly: " + albums.size());
+
+ if (!albums.isEmpty()) {
+ fetchAllAlbumSongsWithCallback(albums, callback);
+ } else {
+ Log.d("ArtistSync", "No albums found in artist response");
+ callback.onSongsCollected(new ArrayList<>());
+ }
+ } else {
+ Log.d("ArtistSync", "Failed to get artist info");
+ callback.onSongsCollected(new ArrayList<>());
+ }
+ }
+
+ @Override
+ public void onFailure(@NonNull Call call, @NonNull Throwable t) {
+ Log.d("ArtistSync", "Error getting artist info: " + t.getMessage());
+ callback.onSongsCollected(new ArrayList<>());
+ }
+ });
+ }
+
+ private void fetchAllAlbumSongsWithCallback(List albums, ArtistSongsCallback callback) {
+ if (albums == null || albums.isEmpty()) {
+ Log.d("ArtistSync", "No albums to process");
+ callback.onSongsCollected(new ArrayList<>());
+ return;
+ }
+
+ List allSongs = new ArrayList<>();
+ AtomicInteger remainingAlbums = new AtomicInteger(albums.size());
+ Log.d("ArtistSync", "Processing " + albums.size() + " albums");
+
+ for (AlbumID3 album : albums) {
+ Log.d("ArtistSync", "Getting tracks for album: " + album.getName());
+ MutableLiveData> albumTracks = albumRepository.getAlbumTracks(album.getId());
+ albumTracks.observeForever(songs -> {
+ Log.d("ArtistSync", "Got " + (songs != null ? songs.size() : 0) + " songs from album");
+ if (songs != null) {
+ allSongs.addAll(songs);
+ }
+ albumTracks.removeObservers(null);
+
+ int remaining = remainingAlbums.decrementAndGet();
+ Log.d("ArtistSync", "Remaining albums: " + remaining);
+
+ if (remaining == 0) {
+ Log.d("ArtistSync", "All albums processed. Total songs: " + allSongs.size());
+ callback.onSongsCollected(allSongs);
+ }
+ });
+ }
+ }
+
+ public interface ArtistSongsCallback {
+ void onSongsCollected(List songs);
+ }
+
public MutableLiveData> getStarredArtists(boolean random, int size) {
MutableLiveData> starredArtists = new MutableLiveData<>(new ArrayList<>());
@@ -89,7 +171,7 @@ public class ArtistRepository {
}
/*
- * Metodo che mi restituisce le informazioni essenzionali dell'artista (cover, numero di album...)
+ * Method that returns essential artist information (cover, album number, etc.)
*/
public void getArtistInfo(List artists, MutableLiveData> list) {
List liveArtists = list.getValue();
diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/DownloadRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/DownloadRepository.java
index c71d2f8c..1d8c935a 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/repository/DownloadRepository.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/repository/DownloadRepository.java
@@ -18,6 +18,20 @@ public class DownloadRepository {
return downloadDao.getAll();
}
+ public List getAllDownloads() {
+ GetAllDownloadsThreadSafe getDownloads = new GetAllDownloadsThreadSafe(downloadDao);
+ Thread thread = new Thread(getDownloads);
+ thread.start();
+
+ try {
+ thread.join();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+
+ return getDownloads.getDownloads();
+ }
+
public Download getDownload(String id) {
Download download = null;
@@ -35,6 +49,24 @@ public class DownloadRepository {
return download;
}
+ private static class GetAllDownloadsThreadSafe implements Runnable {
+ private final DownloadDao downloadDao;
+ private List downloads;
+
+ public GetAllDownloadsThreadSafe(DownloadDao downloadDao) {
+ this.downloadDao = downloadDao;
+ }
+
+ @Override
+ public void run() {
+ downloads = downloadDao.getAllSync();
+ }
+
+ public List getDownloads() {
+ return downloads;
+ }
+ }
+
private static class GetDownloadThreadSafe implements Runnable {
private final DownloadDao downloadDao;
private final String id;
@@ -143,6 +175,12 @@ public class DownloadRepository {
thread.start();
}
+ public void delete(List ids) {
+ DeleteMultipleThreadSafe delete = new DeleteMultipleThreadSafe(downloadDao, ids);
+ Thread thread = new Thread(delete);
+ thread.start();
+ }
+
private static class DeleteThreadSafe implements Runnable {
private final DownloadDao downloadDao;
private final String id;
@@ -157,4 +195,19 @@ public class DownloadRepository {
downloadDao.delete(id);
}
}
+
+ private static class DeleteMultipleThreadSafe implements Runnable {
+ private final DownloadDao downloadDao;
+ private final List ids;
+
+ public DeleteMultipleThreadSafe(DownloadDao downloadDao, List ids) {
+ this.downloadDao = downloadDao;
+ this.ids = ids;
+ }
+
+ @Override
+ public void run() {
+ downloadDao.deleteByIds(ids);
+ }
+ }
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/LyricsRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/LyricsRepository.java
new file mode 100644
index 00000000..fb7a05a3
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/repository/LyricsRepository.java
@@ -0,0 +1,92 @@
+package com.cappielloantonio.tempo.repository;
+
+import androidx.lifecycle.LiveData;
+
+import com.cappielloantonio.tempo.database.AppDatabase;
+import com.cappielloantonio.tempo.database.dao.LyricsDao;
+import com.cappielloantonio.tempo.model.LyricsCache;
+
+public class LyricsRepository {
+ private final LyricsDao lyricsDao = AppDatabase.getInstance().lyricsDao();
+
+ public LyricsCache getLyrics(String songId) {
+ GetLyricsThreadSafe getLyricsThreadSafe = new GetLyricsThreadSafe(lyricsDao, songId);
+ Thread thread = new Thread(getLyricsThreadSafe);
+ thread.start();
+
+ try {
+ thread.join();
+ return getLyricsThreadSafe.getLyrics();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+
+ return null;
+ }
+
+ public LiveData observeLyrics(String songId) {
+ return lyricsDao.observeOne(songId);
+ }
+
+ public void insert(LyricsCache lyricsCache) {
+ InsertThreadSafe insert = new InsertThreadSafe(lyricsDao, lyricsCache);
+ Thread thread = new Thread(insert);
+ thread.start();
+ }
+
+ public void delete(String songId) {
+ DeleteThreadSafe delete = new DeleteThreadSafe(lyricsDao, songId);
+ Thread thread = new Thread(delete);
+ thread.start();
+ }
+
+ private static class GetLyricsThreadSafe implements Runnable {
+ private final LyricsDao lyricsDao;
+ private final String songId;
+ private LyricsCache lyricsCache;
+
+ public GetLyricsThreadSafe(LyricsDao lyricsDao, String songId) {
+ this.lyricsDao = lyricsDao;
+ this.songId = songId;
+ }
+
+ @Override
+ public void run() {
+ lyricsCache = lyricsDao.getOne(songId);
+ }
+
+ public LyricsCache getLyrics() {
+ return lyricsCache;
+ }
+ }
+
+ private static class InsertThreadSafe implements Runnable {
+ private final LyricsDao lyricsDao;
+ private final LyricsCache lyricsCache;
+
+ public InsertThreadSafe(LyricsDao lyricsDao, LyricsCache lyricsCache) {
+ this.lyricsDao = lyricsDao;
+ this.lyricsCache = lyricsCache;
+ }
+
+ @Override
+ public void run() {
+ lyricsDao.insert(lyricsCache);
+ }
+ }
+
+ private static class DeleteThreadSafe implements Runnable {
+ private final LyricsDao lyricsDao;
+ private final String songId;
+
+ public DeleteThreadSafe(LyricsDao lyricsDao, String songId) {
+ this.lyricsDao = lyricsDao;
+ this.songId = songId;
+ }
+
+ @Override
+ public void run() {
+ lyricsDao.delete(songId);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java
index 7884159f..66d0a185 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java
@@ -80,21 +80,52 @@ public class PlaylistRepository {
return listLivePlaylistSongs;
}
- public void addSongToPlaylist(String playlistId, ArrayList songsId) {
+ public MutableLiveData getPlaylist(String id) {
+ MutableLiveData playlistLiveData = new MutableLiveData<>();
+
App.getSubsonicClientInstance(false)
.getPlaylistClient()
- .updatePlaylist(playlistId, null, true, songsId, null)
+ .getPlaylist(id)
.enqueue(new Callback() {
@Override
public void onResponse(@NonNull Call call, @NonNull Response response) {
- Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show();
+ 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 call, @NonNull Throwable t) {
- Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show();
+ playlistLiveData.setValue(null);
}
});
+
+ return playlistLiveData;
+ }
+
+ public void addSongToPlaylist(String playlistId, ArrayList songsId) {
+ if (songsId.isEmpty()) {
+ Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show();
+ } else{
+ App.getSubsonicClientInstance(false)
+ .getPlaylistClient()
+ .updatePlaylist(playlistId, null, true, songsId, null)
+ .enqueue(new Callback() {
+ @Override
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
+ Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show();
+ }
+
+ @Override
+ public void onFailure(@NonNull Call call, @NonNull Throwable t) {
+ Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show();
+ }
+ });
+ }
}
public void createPlaylist(String playlistId, String name, ArrayList songsId) {
@@ -131,23 +162,6 @@ public class PlaylistRepository {
});
}
- public void updatePlaylist(String playlistId, String name, boolean isPublic, ArrayList songIdToAdd, ArrayList songIndexToRemove) {
- App.getSubsonicClientInstance(false)
- .getPlaylistClient()
- .updatePlaylist(playlistId, name, isPublic, songIdToAdd, songIndexToRemove)
- .enqueue(new Callback() {
- @Override
- public void onResponse(@NonNull Call call, @NonNull Response response) {
-
- }
-
- @Override
- public void onFailure(@NonNull Call call, @NonNull Throwable t) {
-
- }
- });
- }
-
public void deletePlaylist(String playlistId) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/EqualizerManager.kt b/app/src/main/java/com/cappielloantonio/tempo/service/EqualizerManager.kt
new file mode 100644
index 00000000..9d8489e3
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/service/EqualizerManager.kt
@@ -0,0 +1,47 @@
+package com.cappielloantonio.tempo.service
+
+import android.media.audiofx.Equalizer
+
+class EqualizerManager {
+
+ private var equalizer: Equalizer? = null
+
+ fun attachToSession(audioSessionId: Int): Boolean {
+ release()
+ if (audioSessionId != 0 && audioSessionId != -1) {
+ try {
+ equalizer = Equalizer(0, audioSessionId).apply {
+ enabled = true
+ }
+ return true
+ } catch (e: Exception) {
+ // Some devices may not support Equalizer or audio session may be invalid
+ equalizer = null
+ }
+ }
+ return false
+ }
+
+ fun setBandLevel(band: Short, level: Short) {
+ equalizer?.setBandLevel(band, level)
+ }
+
+ fun getNumberOfBands(): Short = equalizer?.numberOfBands ?: 0
+
+ fun getBandLevelRange(): ShortArray? = equalizer?.bandLevelRange
+
+ fun getCenterFreq(band: Short): Int? =
+ equalizer?.getCenterFreq(band)?.div(1000)
+
+ fun getBandLevel(band: Short): Short? =
+ equalizer?.getBandLevel(band)
+
+ fun setEnabled(enabled: Boolean) {
+ equalizer?.enabled = enabled
+ }
+
+ fun release() {
+ equalizer?.release()
+ equalizer = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java b/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java
index ea1dcaba..f7cd8a38 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java
@@ -1,11 +1,17 @@
package com.cappielloantonio.tempo.service;
import android.content.ComponentName;
+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;
+import androidx.media3.common.Player;
+import androidx.media3.common.Timeline;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.media3.session.SessionToken;
@@ -21,14 +27,79 @@ import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.Preferences;
+import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
+import java.lang.ref.WeakReference;
import java.util.List;
import java.util.concurrent.ExecutionException;
public class MediaManager {
private static final String TAG = "MediaManager";
+ private static WeakReference attachedBrowserRef = new WeakReference<>(null);
+
+ public static void registerPlaybackObserver(
+ ListenableFuture browserFuture,
+ PlaybackViewModel playbackViewModel
+ ) {
+ if (browserFuture == null) return;
+
+ Futures.addCallback(browserFuture, new FutureCallback() {
+ @Override
+ public void onSuccess(MediaBrowser browser) {
+ MediaBrowser current = attachedBrowserRef.get();
+ if (current != browser) {
+ browser.addListener(new Player.Listener() {
+ @Override
+ public void onEvents(@NonNull Player player, @NonNull Player.Events events) {
+ if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)
+ || events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)
+ || events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
+
+ String mediaId = player.getCurrentMediaItem() != null
+ ? player.getCurrentMediaItem().mediaId
+ : null;
+
+ boolean playing = player.getPlaybackState() == Player.STATE_READY
+ && player.getPlayWhenReady();
+
+ playbackViewModel.update(mediaId, playing);
+ }
+ }
+ });
+
+ String mediaId = browser.getCurrentMediaItem() != null
+ ? browser.getCurrentMediaItem().mediaId
+ : null;
+ boolean playing = browser.getPlaybackState() == Player.STATE_READY && browser.getPlayWhenReady();
+ playbackViewModel.update(mediaId, playing);
+
+ attachedBrowserRef = new WeakReference<>(browser);
+ } else {
+ String mediaId = browser.getCurrentMediaItem() != null
+ ? browser.getCurrentMediaItem().mediaId
+ : null;
+ boolean playing = browser.getPlaybackState() == Player.STATE_READY && browser.getPlayWhenReady();
+ playbackViewModel.update(mediaId, playing);
+ }
+ }
+
+ @Override
+ public void onFailure(@NonNull Throwable t) {
+ Log.e(TAG, "Failed to get MediaBrowser instance", t);
+ }
+ }, MoreExecutors.directExecutor());
+ }
+
+ public static void onBrowserReleased(@Nullable MediaBrowser released) {
+ MediaBrowser attached = attachedBrowserRef.get();
+ if (attached == released) {
+ attachedBrowserRef.clear();
+ }
+ }
public static void reset(ListenableFuture mediaBrowserListenableFuture) {
if (mediaBrowserListenableFuture != null) {
@@ -107,11 +178,24 @@ public class MediaManager {
mediaBrowserListenableFuture.addListener(() -> {
try {
if (mediaBrowserListenableFuture.isDone()) {
- mediaBrowserListenableFuture.get().clearMediaItems();
- mediaBrowserListenableFuture.get().setMediaItems(MappingUtil.mapMediaItems(media));
- mediaBrowserListenableFuture.get().prepare();
- mediaBrowserListenableFuture.get().seekTo(startIndex, 0);
- mediaBrowserListenableFuture.get().play();
+ MediaBrowser browser = mediaBrowserListenableFuture.get();
+ browser.clearMediaItems();
+ browser.setMediaItems(MappingUtil.mapMediaItems(media));
+ 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);
+ }
+ }
+ };
+ browser.addListener(timelineListener);
+
enqueueDatabase(media, true, 0);
}
} catch (ExecutionException | InterruptedException e) {
@@ -139,6 +223,25 @@ public class MediaManager {
}
}
+ public static void playDownloadedMediaItem(ListenableFuture mediaBrowserListenableFuture, MediaItem mediaItem) {
+ if (mediaBrowserListenableFuture != null && mediaItem != null) {
+ mediaBrowserListenableFuture.addListener(() -> {
+ try {
+ if (mediaBrowserListenableFuture.isDone()) {
+ MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
+ mediaBrowser.clearMediaItems();
+ mediaBrowser.setMediaItem(mediaItem);
+ mediaBrowser.prepare();
+ mediaBrowser.play();
+ clearDatabase();
+ }
+ } catch (ExecutionException | InterruptedException e) {
+ e.printStackTrace();
+ }
+ }, MoreExecutors.directExecutor());
+ }
+ }
+
public static void startRadio(ListenableFuture mediaBrowserListenableFuture, InternetRadioStation internetRadioStation) {
if (mediaBrowserListenableFuture != null) {
mediaBrowserListenableFuture.addListener(() -> {
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/RetrofitClient.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/RetrofitClient.kt
index f05238ce..3f9868bd 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/RetrofitClient.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/RetrofitClient.kt
@@ -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()
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/api/system/SystemClient.java b/app/src/main/java/com/cappielloantonio/tempo/subsonic/api/system/SystemClient.java
index d4a6521a..c5227da7 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/api/system/SystemClient.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/api/system/SystemClient.java
@@ -5,6 +5,9 @@ import android.util.Log;
import com.cappielloantonio.tempo.subsonic.RetrofitClient;
import com.cappielloantonio.tempo.subsonic.Subsonic;
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
+import com.cappielloantonio.tempo.util.Preferences;
+
+import java.util.concurrent.TimeUnit;
import retrofit2.Call;
@@ -21,7 +24,15 @@ public class SystemClient {
public Call ping() {
Log.d(TAG, "ping()");
- return systemService.ping(subsonic.getParams());
+ Call pingCall = systemService.ping(subsonic.getParams());
+ if (Preferences.isInUseServerAddressLocal()) {
+ pingCall.timeout()
+ .timeout(1, TimeUnit.SECONDS);
+ } else {
+ pingCall.timeout()
+ .timeout(3, TimeUnit.SECONDS);
+ }
+ return pingCall;
}
public Call getLicense() {
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumID3.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumID3.kt
index 3072a3a4..95c67da2 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumID3.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumID3.kt
@@ -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? = null
- var musicBrainzId: String? = null
- var genres: List? = null
- var artists: List? = null
- var displayArtist: String? = null
- var releaseTypes: List? = null
- var moods: List? = null
- var sortName: String? = null
- var originalReleaseDate: ItemDate? = null
- var releaseDate: ItemDate? = null
- var isCompilation: Boolean? = null
- var discTitles: List? = null
-}
\ No newline at end of file
+ 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? = null,
+ var musicBrainzId: String? = null,
+ var genres: List? = null,
+ var artists: List? = null,
+ var displayArtist: String? = null,
+ var releaseTypes: List? = null,
+ var moods: List? = null,
+ var sortName: String? = null,
+ var originalReleaseDate: ItemDate? = null,
+ var releaseDate: ItemDate? = null,
+ var isCompilation: Boolean? = null,
+ var discTitles: List? = null,
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumWithSongsID3.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumWithSongsID3.kt
index 79c56092..8498e777 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumWithSongsID3.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumWithSongsID3.kt
@@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
-class AlbumWithSongsID3 : AlbumID3(), Parcelable {
+class AlbumWithSongsID3(
@SerializedName("song")
- var songs: List? = null
-}
\ No newline at end of file
+ var songs: List? = null,
+) : AlbumID3(), Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Artist.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Artist.kt
index a13d169d..22aa527b 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Artist.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Artist.kt
@@ -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
-}
\ No newline at end of file
+class Artist(
+ var id: String? = null,
+ var name: String? = null,
+ var starred: Date? = null,
+ var userRating: Int? = null,
+ var averageRating: Double? = null,
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistID3.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistID3.kt
index a17f4aa3..ccf4ee7e 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistID3.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistID3.kt
@@ -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
-}
\ No newline at end of file
+ var coverArtId: String? = null,
+ var albumCount: Int = 0,
+ var starred: Date? = null,
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistWithAlbumsID3.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistWithAlbumsID3.kt
index c22c8207..2e21e111 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistWithAlbumsID3.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistWithAlbumsID3.kt
@@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
-class ArtistWithAlbumsID3 : ArtistID3(), Parcelable {
+class ArtistWithAlbumsID3(
@SerializedName("album")
- var albums: List? = null
-}
\ No newline at end of file
+ var albums: List? = null,
+) : ArtistID3(), Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Directory.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Directory.kt
index 45395ede..f189589e 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Directory.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Directory.kt
@@ -8,15 +8,15 @@ import java.util.Date
@Keep
@Parcelize
-class Directory : Parcelable {
+class Directory(
@SerializedName("child")
- var children: List? = null
- var id: String? = null
+ var children: List? = 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
-}
\ No newline at end of file
+ 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
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/DiscTitle.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/DiscTitle.kt
index 2910d4bf..32caa8cc 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/DiscTitle.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/DiscTitle.kt
@@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
-open class DiscTitle : Parcelable {
- var disc: Int? = null
- var title: String? = null
-}
\ No newline at end of file
+open class DiscTitle(
+ var disc: Int? = null,
+ var title: String? = null,
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Genre.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Genre.kt
index cb1b7719..1db88c40 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Genre.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Genre.kt
@@ -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
-}
\ No newline at end of file
+ var genre: String? = null,
+ var songCount: Int = 0,
+ var albumCount: Int = 0,
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/InternetRadioStation.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/InternetRadioStation.kt
index 0d312ce0..07f00c5b 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/InternetRadioStation.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/InternetRadioStation.kt
@@ -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
-}
\ No newline at end of file
+class InternetRadioStation(
+ var id: String? = null,
+ var name: String? = null,
+ var streamUrl: String? = null,
+ var homePageUrl: String? = null,
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemDate.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemDate.kt
index 5de2fbc6..385b7fd7 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemDate.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemDate.kt
@@ -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())
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemGenre.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemGenre.kt
index ce164fb6..971809ff 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemGenre.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemGenre.kt
@@ -6,6 +6,6 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
-open class ItemGenre : Parcelable {
- var name: String? = null
-}
\ No newline at end of file
+open class ItemGenre(
+ var name: String? = null,
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/MusicFolder.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/MusicFolder.kt
index 7c277df4..e31bf7c8 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/MusicFolder.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/MusicFolder.kt
@@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
-class MusicFolder : Parcelable {
- var id: String? = null
- var name: String? = null
-}
\ No newline at end of file
+class MusicFolder(
+ var id: String? = null,
+ var name: String? = null,
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/NowPlayingEntry.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/NowPlayingEntry.kt
index e5391872..bd69808c 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/NowPlayingEntry.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/NowPlayingEntry.kt
@@ -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
-}
\ No newline at end of file
+ override val id: String,
+ var username: String? = null,
+ var minutesAgo: Int = 0,
+ var playerId: Int = 0,
+ var playerName: String? = null,
+) : Child(id)
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlist.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlist.kt
index 2d85271e..926c2390 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlist.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlist.kt
@@ -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? = 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?,
+ ) : 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
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PlaylistWithSongs.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PlaylistWithSongs.kt
index 92e194c8..350dcbd4 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PlaylistWithSongs.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PlaylistWithSongs.kt
@@ -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? = null
-}
\ No newline at end of file
+ var entries: List? = null,
+) : Playlist(id), Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlists.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlists.kt
index 8aa271a4..34079c76 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlists.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlists.kt
@@ -6,5 +6,5 @@ import com.google.gson.annotations.SerializedName
@Keep
class Playlists(
@SerializedName("playlist")
- var playlists: List? = null
+ var playlists: List? = null,
)
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastChannel.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastChannel.kt
index 088ed97e..b4d124ff 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastChannel.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastChannel.kt
@@ -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? = null
- var id: String? = null
- var url: String? = null
- var title: String? = null
- var description: String? = null
+ var episodes: List? = 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
-}
\ No newline at end of file
+ var coverArtId: String? = null,
+ var originalImageUrl: String? = null,
+ var status: String? = null,
+ var errorMessage: String? = null,
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastEpisode.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastEpisode.kt
index f8893224..fc3fab21 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastEpisode.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastEpisode.kt
@@ -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
-}
\ No newline at end of file
+ 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
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/RecordLabel.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/RecordLabel.kt
index 687f3fd7..52531d90 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/RecordLabel.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/RecordLabel.kt
@@ -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
-}
\ No newline at end of file
+open class RecordLabel(
+ var name: String? = null,
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt
index 193cefc8..986e4b50 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt
@@ -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? = 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
-}
\ No newline at end of file
+ var entries: List? = 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
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/utils/EmptyDateTypeAdapter.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/utils/EmptyDateTypeAdapter.kt
new file mode 100644
index 00000000..bcdd5ee8
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/utils/EmptyDateTypeAdapter.kt
@@ -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 {
+
+ // Define the date formats expected from the Subsonic server.
+ private val dateFormats: List = 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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java
index c959648b..9aa558f8 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java
@@ -1,11 +1,14 @@
package com.cappielloantonio.tempo.ui.activity;
import android.content.Context;
+import android.content.Intent;
import android.content.IntentFilter;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
+import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
+import android.text.TextUtils;
import android.util.Log;
import android.view.View;
@@ -13,7 +16,10 @@ import androidx.annotation.NonNull;
import androidx.core.splashscreen.SplashScreen;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider;
+import androidx.media3.common.MediaItem;
+import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
+import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment;
@@ -31,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;
@@ -54,8 +62,11 @@ 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;
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -69,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);
@@ -77,12 +89,16 @@ public class MainActivity extends BaseActivity {
checkConnectionType();
getOpenSubsonicExtensions();
checkTempoUpdate();
+
+ maybeSchedulePlaybackIntent(getIntent());
}
@Override
protected void onStart() {
super.onStart();
+ pingServer();
initService();
+ consumePendingPlaybackIntent();
}
@Override
@@ -98,6 +114,14 @@ public class MainActivity extends BaseActivity {
bind = null;
}
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ maybeSchedulePlaybackIntent(intent);
+ consumePendingPlaybackIntent();
+ }
+
@Override
public void onBackPressed() {
if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED)
@@ -292,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() {
@@ -351,6 +393,7 @@ public class MainActivity extends BaseActivity {
Preferences.switchInUseServerAddress();
App.refreshSubsonicClient();
pingServer();
+ resetView();
} else {
Preferences.setOpenSubsonic(subsonicResponse.getOpenSubsonic() != null && subsonicResponse.getOpenSubsonic());
}
@@ -361,6 +404,7 @@ public class MainActivity extends BaseActivity {
Preferences.switchInUseServerAddress();
App.refreshSubsonicClient();
pingServer();
+ resetView();
} else {
mainViewModel.ping().observe(this, subsonicResponse -> {
if (subsonicResponse == null) {
@@ -376,6 +420,13 @@ public class MainActivity extends BaseActivity {
}
}
+ private void resetView() {
+ resetViewModel();
+ int id = Objects.requireNonNull(navController.getCurrentDestination()).getId();
+ navController.popBackStack(id, true);
+ navController.navigate(id);
+ }
+
private void getOpenSubsonicExtensions() {
if (Preferences.getToken() != null) {
mainViewModel.getOpenSubsonicExtensions().observe(this, openSubsonicExtensions -> {
@@ -408,4 +459,98 @@ public class MainActivity extends BaseActivity {
}
}
}
-}
\ No newline at end of file
+
+ private void maybeSchedulePlaybackIntent(Intent intent) {
+ if (intent == null) return;
+ if (Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD.equals(intent.getAction())
+ || intent.hasExtra(Constants.EXTRA_DOWNLOAD_URI)) {
+ pendingDownloadPlaybackIntent = new Intent(intent);
+ }
+ handleAssetLinkIntent(intent);
+ }
+
+ private void consumePendingPlaybackIntent() {
+ if (pendingDownloadPlaybackIntent == null) return;
+ Intent intent = pendingDownloadPlaybackIntent;
+ pendingDownloadPlaybackIntent = null;
+ 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)) {
+ return;
+ }
+
+ Uri uri = Uri.parse(uriString);
+ String mediaId = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID);
+ if (TextUtils.isEmpty(mediaId)) {
+ mediaId = uri.toString();
+ }
+
+ String title = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_TITLE);
+ String artist = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ARTIST);
+ String album = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ALBUM);
+ int duration = intent.getIntExtra(Constants.EXTRA_DOWNLOAD_DURATION, 0);
+
+ Bundle extras = new Bundle();
+ extras.putString("id", mediaId);
+ extras.putString("title", title);
+ extras.putString("artist", artist);
+ extras.putString("album", album);
+ extras.putString("uri", uri.toString());
+ extras.putString("type", Constants.MEDIA_TYPE_MUSIC);
+ extras.putInt("duration", duration);
+
+ MediaMetadata.Builder metadataBuilder = new MediaMetadata.Builder()
+ .setExtras(extras)
+ .setIsBrowsable(false)
+ .setIsPlayable(true);
+
+ if (!TextUtils.isEmpty(title)) metadataBuilder.setTitle(title);
+ if (!TextUtils.isEmpty(artist)) metadataBuilder.setArtist(artist);
+ if (!TextUtils.isEmpty(album)) metadataBuilder.setAlbumTitle(album);
+
+ MediaItem mediaItem = new MediaItem.Builder()
+ .setMediaId(mediaId)
+ .setMediaMetadata(metadataBuilder.build())
+ .setUri(uri)
+ .setMimeType(MimeTypes.BASE_TYPE_AUDIO)
+ .setRequestMetadata(new MediaItem.RequestMetadata.Builder()
+ .setMediaUri(uri)
+ .setExtras(extras)
+ .build())
+ .build();
+
+ MediaManager.playDownloadedMediaItem(getMediaBrowserListenableFuture(), mediaItem);
+ }
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCatalogueAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCatalogueAdapter.java
index 1f360c80..69583a40 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCatalogueAdapter.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCatalogueAdapter.java
@@ -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 implements Filterable {
@@ -152,12 +153,20 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter 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 +175,23 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter 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;
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/DownloadHorizontalAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/DownloadHorizontalAdapter.java
index 5c065691..ff06f27a 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/DownloadHorizontalAdapter.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/DownloadHorizontalAdapter.java
@@ -191,7 +191,7 @@ public class DownloadHorizontalAdapter extends RecyclerView.Adapter {
+ private static final String TAG = "PlayerSongQueueAdapter";
private final ClickCallback click;
private ListenableFuture mediaBrowserListenableFuture;
private List songs;
+ private String currentPlayingId;
+ private boolean isPlaying;
+ private List currentPlayingPositions = Collections.emptyList();
+
public PlayerSongQueueAdapter(ClickCallback click) {
this.click = click;
this.songs = Collections.emptyList();
@@ -104,6 +112,46 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter {
+ mediaBrowserListenableFuture.addListener(() -> {
+ try {
+ MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
+ int pos = holder.getBindingAdapterPosition();
+ Child s = songs.get(pos);
+ if (currentPlayingId != null && currentPlayingId.equals(s.getId())) {
+ if (isPlaying) {
+ mediaBrowser.pause();
+ } else {
+ mediaBrowser.play();
+ }
+ } else {
+ mediaBrowser.seekTo(pos, 0);
+ mediaBrowser.play();
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "Error obtaining MediaBrowser", e);
+ }
+ }, MoreExecutors.directExecutor());
+
+ });
+ bindPlaybackState(holder, song);
+ }
+
+ private void bindPlaybackState(@NonNull PlayerSongQueueAdapter.ViewHolder holder, @NonNull Child song) {
+ boolean isCurrent = currentPlayingId != null && currentPlayingId.equals(song.getId());
+
+ if (isCurrent) {
+ holder.item.playPauseIcon.setVisibility(View.VISIBLE);
+ if (isPlaying) {
+ holder.item.playPauseIcon.setImageResource(R.drawable.ic_pause);
+ } else {
+ holder.item.playPauseIcon.setImageResource(R.drawable.ic_play);
+ }
+ holder.item.coverArtOverlay.setVisibility(View.VISIBLE);
+ } else {
+ holder.item.playPauseIcon.setVisibility(View.INVISIBLE);
+ holder.item.coverArtOverlay.setVisibility(View.INVISIBLE);
+ }
}
public List getItems() {
@@ -132,6 +180,46 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter oldPositions = currentPlayingPositions;
+
+ this.currentPlayingId = mediaId;
+ this.isPlaying = playing;
+
+ if (Objects.equals(oldId, mediaId) && oldPlaying == playing) {
+ List newPositionsCheck = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
+ if (oldPositions.equals(newPositionsCheck)) {
+ return;
+ }
+ }
+
+ currentPlayingPositions = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
+
+ for (int pos : oldPositions) {
+ if (pos >= 0 && pos < songs.size()) {
+ notifyItemChanged(pos, "payload_playback");
+ }
+ }
+ for (int pos : currentPlayingPositions) {
+ if (!oldPositions.contains(pos) && pos >= 0 && pos < songs.size()) {
+ notifyItemChanged(pos, "payload_playback");
+ }
+ }
+ }
+
+ private List findPositionsById(String id) {
+ if (id == null) return Collections.emptyList();
+ List positions = new ArrayList<>();
+ for (int i = 0; i < songs.size(); i++) {
+ if (id.equals(songs.get(i).getId())) {
+ positions.add(i);
+ }
+ }
+ return positions;
+ }
+
public Child getItem(int id) {
return songs.get(id);
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java
index a1630bca..1d78f2ea 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java
@@ -1,6 +1,8 @@
package com.cappielloantonio.tempo.ui.adapter;
+import android.app.Activity;
import android.os.Bundle;
+import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -9,7 +11,9 @@ import android.widget.Filterable;
import androidx.annotation.NonNull;
import androidx.appcompat.content.res.AppCompatResources;
+import androidx.lifecycle.LifecycleOwner;
import androidx.media3.common.util.UnstableApi;
+import androidx.media3.session.MediaBrowser;
import androidx.recyclerview.widget.RecyclerView;
import com.cappielloantonio.tempo.R;
@@ -21,8 +25,11 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.DiscTitle;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
+import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
+import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
import java.util.Collections;
@@ -30,6 +37,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
+import java.util.concurrent.ExecutionException;
@UnstableApi
public class SongHorizontalAdapter extends RecyclerView.Adapter implements Filterable {
@@ -42,6 +50,11 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter songs;
private String currentFilter;
+ private String currentPlayingId;
+ private boolean isPlaying;
+ private List currentPlayingPositions = Collections.emptyList();
+ private ListenableFuture mediaBrowserListenableFuture;
+
private final Filter filtering = new Filter() {
@Override
protected FilterResults performFiltering(CharSequence constraint) {
@@ -70,10 +83,16 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter) results.values;
notifyDataSetChanged();
+
+ for (int pos : currentPlayingPositions) {
+ if (pos >= 0 && pos < songs.size()) {
+ notifyItemChanged(pos, "payload_playback");
+ }
+ }
}
};
- public SongHorizontalAdapter(ClickCallback click, boolean showCoverArt, boolean showAlbum, AlbumID3 album) {
+ public SongHorizontalAdapter(LifecycleOwner lifecycleOwner, ClickCallback click, boolean showCoverArt, boolean showAlbum, AlbumID3 album) {
this.click = click;
this.showCoverArt = showCoverArt;
this.showAlbum = showAlbum;
@@ -81,6 +100,11 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter payloads) {
+ if (!payloads.isEmpty() && payloads.contains("payload_playback")) {
+ bindPlaybackState(holder, songs.get(position));
+ } else {
+ super.onBindViewHolder(holder, position, payloads);
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
Child song = songs.get(position);
holder.item.searchResultSongTitleTextView.setText(song.getTitle());
@@ -109,10 +142,18 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter oldPositions = currentPlayingPositions;
+
+ this.currentPlayingId = mediaId;
+ this.isPlaying = playing;
+
+ if (Objects.equals(oldId, mediaId) && oldPlaying == playing) {
+ List newPositionsCheck = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
+ if (oldPositions.equals(newPositionsCheck)) {
+ return;
+ }
+ }
+
+ currentPlayingPositions = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
+
+ for (int pos : oldPositions) {
+ if (pos >= 0 && pos < songs.size()) {
+ notifyItemChanged(pos, "payload_playback");
+ }
+ }
+ for (int pos : currentPlayingPositions) {
+ if (!oldPositions.contains(pos) && pos >= 0 && pos < songs.size()) {
+ notifyItemChanged(pos, "payload_playback");
+ }
+ }
+ }
+
+ private List findPositionsById(String id) {
+ if (id == null) return Collections.emptyList();
+ List positions = new ArrayList<>();
+ for (int i = 0; i < songs.size(); i++) {
+ if (id.equals(songs.get(i).getId())) {
+ positions.add(i);
+ }
+ }
+ return positions;
+ }
+
@Override
public Filter getFilter() {
return filtering;
@@ -215,11 +329,29 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter(MusicUtil.limitPlayableMedia(songs, getBindingAdapterPosition())));
bundle.putInt(Constants.ITEM_POSITION, MusicUtil.getPlayableMediaPosition(songs, getBindingAdapterPosition()));
- click.onMediaClick(bundle);
+ if (tappedSong.getId().equals(currentPlayingId)) {
+ Log.i("SongHorizontalAdapter", "Tapping on currently playing song, toggling playback");
+ try{
+ MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
+ Log.i("SongHorizontalAdapter", "MediaBrowser retrieved, isPlaying: " + isPlaying);
+ if (isPlaying) {
+ mediaBrowser.pause();
+ } else {
+ mediaBrowser.play();
+ }
+ } catch (ExecutionException | InterruptedException e) {
+ Log.e("SongHorizontalAdapter", "Error getting MediaBrowser", e);
+ }
+ } else {
+ click.onMediaClick(bundle);
+ }
}
private boolean onLongClick() {
@@ -247,4 +379,8 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter mediaBrowserListenableFuture) {
+ this.mediaBrowserListenableFuture = mediaBrowserListenableFuture;
+ }
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DeleteDownloadStorageDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DeleteDownloadStorageDialog.java
index a8d84d0d..877831f1 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DeleteDownloadStorageDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DeleteDownloadStorageDialog.java
@@ -3,6 +3,9 @@ package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog;
import android.os.Bundle;
import android.widget.Button;
+import android.net.Uri;
+
+import androidx.documentfile.provider.DocumentFile;
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
@@ -12,6 +15,9 @@ import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.DialogDeleteDownloadStorageBinding;
import com.cappielloantonio.tempo.util.DownloadUtil;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
+import com.cappielloantonio.tempo.util.ExternalDownloadMetadataStore;
+import com.cappielloantonio.tempo.util.Preferences;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
@OptIn(markerClass = UnstableApi.class)
@@ -42,7 +48,21 @@ public class DeleteDownloadStorageDialog extends DialogFragment {
if (dialog != null) {
Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
positiveButton.setOnClickListener(v -> {
- DownloadUtil.getDownloadTracker(requireContext()).removeAll();
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).removeAll();
+ }
+
+ String uriString = Preferences.getDownloadDirectoryUri();
+ if (uriString != null) {
+ DocumentFile directory = DocumentFile.fromTreeUri(requireContext(), Uri.parse(uriString));
+ if (directory != null && directory.canWrite()) {
+ for (DocumentFile file : directory.listFiles()) {
+ file.delete();
+ }
+ }
+ ExternalAudioReader.refreshCache();
+ ExternalDownloadMetadataStore.clear();
+ }
dialog.dismiss();
});
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadDirectoryPickerDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadDirectoryPickerDialog.java
new file mode 100644
index 00000000..62dcd404
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadDirectoryPickerDialog.java
@@ -0,0 +1,63 @@
+package com.cappielloantonio.tempo.ui.dialog;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.cappielloantonio.tempo.R;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
+import com.cappielloantonio.tempo.util.Preferences;
+
+public class DownloadDirectoryPickerDialog extends DialogFragment {
+
+ private ActivityResultLauncher folderPickerLauncher;
+
+ @NonNull
+ @Override
+ public android.app.Dialog onCreateDialog(Bundle savedInstanceState) {
+ // Register launcher *before* button triggers
+ folderPickerLauncher = registerForActivityResult(
+ new ActivityResultContracts.StartActivityForResult(),
+ result -> {
+ if (result.getResultCode() == android.app.Activity.RESULT_OK) {
+ Intent data = result.getData();
+ if (data != null) {
+ Uri uri = data.getData();
+ if (uri != null) {
+ requireContext().getContentResolver().takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ );
+
+ Preferences.setDownloadDirectoryUri(uri.toString());
+ ExternalAudioReader.refreshCache();
+
+ Toast.makeText(requireContext(), "Download directory set:\n" + uri.toString(), Toast.LENGTH_LONG).show();
+ }
+ }
+ }
+ }
+ );
+
+ return new MaterialAlertDialogBuilder(requireContext())
+ .setTitle("Set Download Directory")
+ .setMessage("Choose a folder where downloaded songs will be stored.")
+ .setPositiveButton("Choose Folder", (dialog, which) -> {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+ intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
+ | Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ folderPickerLauncher.launch(intent);
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .create();
+ }
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadStorageDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadStorageDialog.java
index f5ff1577..766064e2 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadStorageDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadStorageDialog.java
@@ -34,6 +34,7 @@ public class DownloadStorageDialog extends DialogFragment {
.setTitle(R.string.download_storage_dialog_title)
.setPositiveButton(R.string.download_storage_external_dialog_positive_button, null)
.setNegativeButton(R.string.download_storage_internal_dialog_negative_button, null)
+ .setNeutralButton(R.string.download_storage_directory_dialog_neutral_button, null)
.create();
}
@@ -74,6 +75,20 @@ public class DownloadStorageDialog extends DialogFragment {
dialog.dismiss();
});
+
+ Button neutralButton = dialog.getButton(Dialog.BUTTON_NEUTRAL);
+ neutralButton.setOnClickListener(v -> {
+ int currentPreference = Preferences.getDownloadStoragePreference();
+ int newPreference = 2;
+
+ if (currentPreference != newPreference) {
+ Preferences.setDownloadStoragePreference(newPreference);
+ DownloadUtil.getDownloadTracker(requireContext()).removeAll();
+ dialogClickCallback.onNeutralClick();
+ }
+
+ dialog.dismiss();
+ });
}
}
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java
index 360a5ec5..a6842512 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java
@@ -27,6 +27,7 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba
private PlaylistDialogHorizontalAdapter playlistDialogHorizontalAdapter;
+
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
@@ -100,8 +101,7 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba
public void onPlaylistClick(Bundle bundle) {
if (playlistChooserViewModel.getSongsToAdd() != null && !playlistChooserViewModel.getSongsToAdd().isEmpty()) {
Playlist playlist = bundle.getParcelable(Constants.PLAYLIST_OBJECT);
- playlistChooserViewModel.addSongsToPlaylist(playlist.getId());
- dismiss();
+ playlistChooserViewModel.addSongsToPlaylist(this, getDialog(), playlist.getId());
} else {
Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_add_failure, Toast.LENGTH_SHORT).show();
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredArtistSyncDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredArtistSyncDialog.java
new file mode 100644
index 00000000..448ca072
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredArtistSyncDialog.java
@@ -0,0 +1,88 @@
+package com.cappielloantonio.tempo.ui.dialog;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.widget.Button;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.OptIn;
+import androidx.fragment.app.DialogFragment;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.media3.common.util.UnstableApi;
+
+import com.cappielloantonio.tempo.R;
+import com.cappielloantonio.tempo.databinding.DialogStarredArtistSyncBinding;
+import com.cappielloantonio.tempo.model.Download;
+import com.cappielloantonio.tempo.util.DownloadUtil;
+import com.cappielloantonio.tempo.util.MappingUtil;
+import com.cappielloantonio.tempo.util.Preferences;
+import com.cappielloantonio.tempo.viewmodel.StarredArtistsSyncViewModel;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.util.stream.Collectors;
+
+@OptIn(markerClass = UnstableApi.class)
+public class StarredArtistSyncDialog extends DialogFragment {
+ private StarredArtistsSyncViewModel starredArtistsSyncViewModel;
+
+ private Runnable onCancel;
+
+ public StarredArtistSyncDialog(Runnable onCancel) {
+ this.onCancel = onCancel;
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ DialogStarredArtistSyncBinding bind = DialogStarredArtistSyncBinding.inflate(getLayoutInflater());
+
+ starredArtistsSyncViewModel = new ViewModelProvider(requireActivity()).get(StarredArtistsSyncViewModel.class);
+
+ return new MaterialAlertDialogBuilder(getActivity())
+ .setView(bind.getRoot())
+ .setTitle(R.string.starred_artist_sync_dialog_title)
+ .setPositiveButton(R.string.starred_sync_dialog_positive_button, null)
+ .setNeutralButton(R.string.starred_sync_dialog_neutral_button, null)
+ .setNegativeButton(R.string.starred_sync_dialog_negative_button, null)
+ .create();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ setButtonAction(requireContext());
+ }
+
+ private void setButtonAction(Context context) {
+ androidx.appcompat.app.AlertDialog dialog = (androidx.appcompat.app.AlertDialog) getDialog();
+
+ if (dialog != null) {
+ Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
+ positiveButton.setOnClickListener(v -> {
+ starredArtistsSyncViewModel.getStarredArtistSongs(requireActivity()).observe(this, allSongs -> {
+ if (allSongs != null && !allSongs.isEmpty()) {
+ DownloadUtil.getDownloadTracker(context).download(
+ MappingUtil.mapDownloads(allSongs),
+ allSongs.stream().map(Download::new).collect(Collectors.toList())
+ );
+ }
+ dialog.dismiss();
+ });
+ });
+
+ Button neutralButton = dialog.getButton(Dialog.BUTTON_NEUTRAL);
+ neutralButton.setOnClickListener(v -> {
+ Preferences.setStarredArtistsSyncEnabled(true);
+ dialog.dismiss();
+ });
+
+ Button negativeButton = dialog.getButton(Dialog.BUTTON_NEGATIVE);
+ negativeButton.setOnClickListener(v -> {
+ Preferences.setStarredArtistsSyncEnabled(false);
+ if (onCancel != null) onCancel.run();
+ dialog.dismiss();
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredSyncDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredSyncDialog.java
index 3d9b07a9..d3edfdf7 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredSyncDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredSyncDialog.java
@@ -61,7 +61,7 @@ public class StarredSyncDialog extends DialogFragment {
Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
positiveButton.setOnClickListener(v -> {
starredSyncViewModel.getStarredTracks(requireActivity()).observe(requireActivity(), songs -> {
- if (songs != null) {
+ if (songs != null && Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownloads(songs),
songs.stream().map(Download::new).collect(Collectors.toList())
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java
index f866c250..e6b91f01 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java
@@ -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;
+ });
+ }
+
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumCatalogueFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumCatalogueFragment.java
index bb62e939..4061cccd 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumCatalogueFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumCatalogueFragment.java
@@ -32,17 +32,19 @@ 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;
@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;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
@@ -115,7 +117,10 @@ 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 -> {
+ albumAdapter.setItems(albums);
+ applySavedSortOrder();
+ });
bind.albumCatalogueRecyclerView.setOnTouchListener((v, event) -> {
hideKeyboard(v);
@@ -137,6 +142,44 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
});
}
+ private void applySavedSortOrder() {
+ String savedSortOrder = Preferences.getAlbumSortOrder();
+ currentSortOrder = savedSortOrder;
+ albumAdapter.sort(savedSortOrder);
+ updateSortIndicator();
+ }
+
+ 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 +215,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;
+ albumAdapter.sort(newSortOrder);
+ Preferences.setAlbumSortOrder(newSortOrder);
+ updateSortIndicator();
return true;
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java
index 03e71e10..a7f5d174 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java
@@ -35,11 +35,15 @@ 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;
import com.cappielloantonio.tempo.util.MusicUtil;
+import com.cappielloantonio.tempo.util.ExternalAudioWriter;
+import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.AlbumPageViewModel;
+import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
@@ -52,6 +56,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
private FragmentAlbumPageBinding bind;
private MainActivity activity;
private AlbumPageViewModel albumPageViewModel;
+ private PlaybackViewModel playbackViewModel;
private SongHorizontalAdapter songHorizontalAdapter;
private ListenableFuture mediaBrowserListenableFuture;
@@ -74,6 +79,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind = FragmentAlbumPageBinding.inflate(inflater, container, false);
View view = bind.getRoot();
albumPageViewModel = new ViewModelProvider(requireActivity()).get(AlbumPageViewModel.class);
+ playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
initAppBar();
@@ -91,6 +97,14 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
super.onStart();
initializeMediaBrowser();
+
+ MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
+ observePlayback();
+ }
+
+ public void onResume() {
+ super.onResume();
+ if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
}
@Override
@@ -119,7 +133,14 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
if (item.getItemId() == R.id.action_download_album) {
albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> {
- DownloadUtil.getDownloadTracker(requireContext()).download(MappingUtil.mapDownloads(songs), songs.stream().map(Download::new).collect(Collectors.toList()));
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).download(
+ MappingUtil.mapDownloads(songs),
+ songs.stream().map(Download::new).collect(Collectors.toList())
+ );
+ } else {
+ songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
+ }
});
return true;
}
@@ -157,8 +178,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());
@@ -269,10 +317,15 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.songRecyclerView.setHasFixedSize(true);
- songHorizontalAdapter = new SongHorizontalAdapter(this, false, false, album);
+ songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, false, false, album);
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
+ setMediaBrowserListenableFuture();
+ reapplyPlayback();
- albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> songHorizontalAdapter.setItems(songs));
+ albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> {
+ songHorizontalAdapter.setItems(songs);
+ reapplyPlayback();
+ });
}
});
}
@@ -295,4 +348,50 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
public void onMediaLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
}
-}
\ No newline at end of file
+
+ private void observePlayback() {
+ playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
+ if (songHorizontalAdapter != null) {
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
+ if (songHorizontalAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ }
+
+ private void reapplyPlayback() {
+ if (songHorizontalAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ }
+
+ 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());
+ }
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java
index 9dccc83e..fe24f06e 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java
@@ -29,19 +29,16 @@ import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
-import com.cappielloantonio.tempo.ui.adapter.AlbumArtistPageOrSimilarAdapter;
import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter;
import com.cappielloantonio.tempo.ui.adapter.ArtistCatalogueAdapter;
-import com.cappielloantonio.tempo.ui.adapter.ArtistSimilarAdapter;
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
-import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.ArtistPageViewModel;
+import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.List;
@UnstableApi
@@ -49,6 +46,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
private FragmentArtistPageBinding bind;
private MainActivity activity;
private ArtistPageViewModel artistPageViewModel;
+ private PlaybackViewModel playbackViewModel;
private SongHorizontalAdapter songHorizontalAdapter;
private AlbumCatalogueAdapter albumCatalogueAdapter;
@@ -63,6 +61,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
bind = FragmentArtistPageBinding.inflate(inflater, container, false);
View view = bind.getRoot();
artistPageViewModel = new ViewModelProvider(requireActivity()).get(ArtistPageViewModel.class);
+ playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
initAppBar();
@@ -80,6 +79,13 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
super.onStart();
initializeMediaBrowser();
+ MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
+ observePlayback();
+ }
+
+ public void onResume() {
+ super.onResume();
+ if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
}
@Override
@@ -159,7 +165,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
bind.artistPageRadioButton.setOnClickListener(v -> {
artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), songs -> {
- if (!songs.isEmpty()) {
+ if (songs != null && !songs.isEmpty()) {
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
activity.setBottomSheetInPeek(true);
} else {
@@ -172,8 +178,10 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
private void initTopSongsView() {
bind.mostStreamedSongRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
- songHorizontalAdapter = new SongHorizontalAdapter(this, true, true, null);
+ songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, true, null);
bind.mostStreamedSongRecyclerView.setAdapter(songHorizontalAdapter);
+ setMediaBrowserListenableFuture();
+ reapplyPlayback();
artistPageViewModel.getArtistTopSongList().observe(getViewLifecycleOwner(), songs -> {
if (songs == null) {
if (bind != null) bind.artistPageTopSongsSector.setVisibility(View.GONE);
@@ -183,6 +191,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
if (bind != null)
bind.artistPageShuffleButton.setEnabled(!songs.isEmpty());
songHorizontalAdapter.setItems(songs);
+ reapplyPlayback();
}
});
}
@@ -273,4 +282,31 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
public void onArtistLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.artistBottomSheetDialog, bundle);
}
+
+ private void observePlayback() {
+ playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
+ if (songHorizontalAdapter != null) {
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
+ if (songHorizontalAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ }
+
+ private void reapplyPlayback() {
+ if (songHorizontalAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ }
+
+ private void setMediaBrowserListenableFuture() {
+ songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java
index 2423011e..104d79f8 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java
@@ -33,7 +33,9 @@ import com.cappielloantonio.tempo.ui.adapter.MusicDirectoryAdapter;
import com.cappielloantonio.tempo.ui.dialog.DownloadDirectoryDialog;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
+import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.util.MappingUtil;
+import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.DirectoryViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@@ -109,10 +111,14 @@ public class DirectoryFragment extends Fragment implements ClickCallback {
directoryViewModel.loadMusicDirectory(getArguments().getString(Constants.MUSIC_DIRECTORY_ID)).observe(getViewLifecycleOwner(), directory -> {
if (isVisible() && getActivity() != null) {
List songs = directory.getChildren().stream().filter(child -> !child.isDir()).collect(Collectors.toList());
- DownloadUtil.getDownloadTracker(requireContext()).download(
- MappingUtil.mapDownloads(songs),
- songs.stream().map(Download::new).collect(Collectors.toList())
- );
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).download(
+ MappingUtil.mapDownloads(songs),
+ songs.stream().map(Download::new).collect(Collectors.toList())
+ );
+ } else {
+ songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
+ }
}
});
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java
index b8108fe3..be2b3eb0 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java
@@ -28,11 +28,17 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.DownloadHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.DownloadViewModel;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.common.util.concurrent.ListenableFuture;
+import android.content.Intent;
+import android.app.Activity;
+import android.net.Uri;
+import android.widget.Toast;
+
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -40,6 +46,7 @@ import java.util.Objects;
@UnstableApi
public class DownloadFragment extends Fragment implements ClickCallback {
private static final String TAG = "DownloadFragment";
+ private static final int REQUEST_CODE_PICK_DIRECTORY = 1002;
private FragmentDownloadBinding bind;
private MainActivity activity;
@@ -129,8 +136,27 @@ public class DownloadFragment extends Fragment implements ClickCallback {
}
});
+ downloadViewModel.getRefreshResult().observe(getViewLifecycleOwner(), count -> {
+ if (count == null || bind == null) {
+ return;
+ }
+
+ if (count == -1) {
+ Toast.makeText(requireContext(), R.string.download_refresh_no_directory, Toast.LENGTH_SHORT).show();
+ } else if (count == 0) {
+ Toast.makeText(requireContext(), R.string.download_refresh_no_changes, Toast.LENGTH_SHORT).show();
+ } else {
+ Toast.makeText(
+ requireContext(),
+ getResources().getQuantityString(R.plurals.download_refresh_removed, count, count),
+ Toast.LENGTH_SHORT
+ ).show();
+ }
+ });
+
bind.downloadedGroupByImageView.setOnClickListener(view -> showPopupMenu(view, R.menu.download_popup_menu));
bind.downloadedGoBackImageView.setOnClickListener(view -> downloadViewModel.popViewStack());
+ bind.downloadedRefreshImageView.setOnClickListener(view -> downloadViewModel.refreshExternalDownloads());
}
private void finishDownloadView(List songs) {
@@ -216,6 +242,10 @@ public class DownloadFragment extends Fragment implements ClickCallback {
downloadViewModel.initViewStack(new DownloadStack(Constants.DOWNLOAD_TYPE_YEAR, null));
Preferences.setDefaultDownloadViewType(Constants.DOWNLOAD_TYPE_YEAR);
return true;
+ } else if (menuItem.getItemId() == R.id.menu_download_set_directory) {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+ startActivityForResult(intent, REQUEST_CODE_PICK_DIRECTORY);
+ return true;
}
return false;
@@ -267,4 +297,21 @@ public class DownloadFragment extends Fragment implements ClickCallback {
public void onDownloadGroupLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.downloadBottomSheetDialog, bundle);
}
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode == REQUEST_CODE_PICK_DIRECTORY && resultCode == Activity.RESULT_OK) {
+ Uri uri = data.getData();
+ if (uri != null) {
+ requireContext().getContentResolver().takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ );
+ Preferences.setDownloadDirectoryUri(uri.toString());
+ ExternalAudioReader.refreshCache();
+ Toast.makeText(requireContext(), "Download directory set", Toast.LENGTH_SHORT).show();
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt
new file mode 100644
index 00000000..a5115f14
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt
@@ -0,0 +1,237 @@
+package com.cappielloantonio.tempo.ui.fragment
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Bundle
+import android.os.IBinder
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.*
+import androidx.annotation.OptIn
+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.MediaService
+import com.cappielloantonio.tempo.util.Preferences
+
+class EqualizerFragment : Fragment() {
+
+ private var equalizerManager: EqualizerManager? = null
+ private lateinit var eqBandsContainer: LinearLayout
+ private lateinit var eqSwitch: Switch
+ private lateinit var resetButton: Button
+ private lateinit var safeSpace: Space
+ private val bandSeekBars = mutableListOf()
+
+ private val connection = object : ServiceConnection {
+ @OptIn(UnstableApi::class)
+ override fun onServiceConnected(className: ComponentName, service: IBinder) {
+ val binder = service as MediaService.LocalBinder
+ equalizerManager = binder.getEqualizerManager()
+ initUI()
+ restoreEqualizerPreferences()
+ }
+
+ override fun onServiceDisconnected(arg0: ComponentName) {
+ equalizerManager = null
+ }
+ }
+
+ @OptIn(UnstableApi::class)
+ override fun onStart() {
+ super.onStart()
+ Intent(requireContext(), MediaService::class.java).also { intent ->
+ intent.action = MediaService.ACTION_BIND_EQUALIZER
+ requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE)
+ }
+ }
+
+ override fun onStop() {
+ super.onStop()
+ requireActivity().unbindService(connection)
+ equalizerManager = null
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ val root = inflater.inflate(R.layout.fragment_equalizer, container, false)
+ eqSwitch = root.findViewById(R.id.equalizer_switch)
+ eqSwitch.isChecked = Preferences.isEqualizerEnabled()
+ eqSwitch.jumpDrawablesToCurrentState()
+ return root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ eqBandsContainer = view.findViewById(R.id.eq_bands_container)
+ resetButton = view.findViewById(R.id.equalizer_reset_button)
+ safeSpace = view.findViewById(R.id.equalizer_bottom_space)
+ }
+
+ private fun initUI() {
+ val manager = equalizerManager
+ val notSupportedView = view?.findViewById(R.id.equalizer_not_supported_container)
+ val switchRow = view?.findViewById(R.id.equalizer_switch_row)
+
+ if (manager == null || manager.getNumberOfBands().toInt() == 0) {
+ switchRow?.visibility = View.GONE
+ resetButton.visibility = View.GONE
+ eqBandsContainer.visibility = View.GONE
+ safeSpace.visibility = View.GONE
+ notSupportedView?.visibility = View.VISIBLE
+ return
+ }
+
+ notSupportedView?.visibility = View.GONE
+ switchRow?.visibility = View.VISIBLE
+ resetButton.visibility = View.VISIBLE
+ eqBandsContainer.visibility = View.VISIBLE
+ safeSpace.visibility = View.VISIBLE
+
+ eqSwitch.setOnCheckedChangeListener(null)
+ updateUiEnabledState(eqSwitch.isChecked)
+ eqSwitch.setOnCheckedChangeListener { _, isChecked ->
+ manager.setEnabled(isChecked)
+ Preferences.setEqualizerEnabled(isChecked)
+ updateUiEnabledState(isChecked)
+ }
+
+ createBandSliders()
+
+ resetButton.setOnClickListener {
+ resetEqualizer()
+ saveBandLevelsToPreferences()
+ }
+ }
+
+ private fun updateUiEnabledState(isEnabled: Boolean) {
+ resetButton.isEnabled = isEnabled
+ bandSeekBars.forEach { it.isEnabled = isEnabled }
+ }
+
+ private fun formatDb(value: Int): String = if (value > 0) "+$value dB" else "$value dB"
+
+ private fun createBandSliders() {
+ val manager = equalizerManager ?: return
+ eqBandsContainer.removeAllViews()
+ bandSeekBars.clear()
+ val bands = manager.getNumberOfBands()
+ val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500)
+ val minLevelDb = bandLevelRange[0] / 100
+ val maxLevelDb = bandLevelRange[1] / 100
+
+ val savedLevels = Preferences.getEqualizerBandLevels(bands)
+ for (i in 0 until bands) {
+ val band = i.toShort()
+ val freq = manager.getCenterFreq(band) ?: 0
+
+ val row = LinearLayout(requireContext()).apply {
+ orientation = LinearLayout.HORIZONTAL
+ layoutParams = LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ ).apply {
+ val topBottomMarginDp = 16
+ topMargin = topBottomMarginDp.dpToPx(context)
+ bottomMargin = topBottomMarginDp.dpToPx(context)
+ }
+ setPadding(0, 8, 0, 8)
+ }
+
+ val freqLabel = TextView(requireContext(), null, 0, R.style.LabelSmall).apply {
+ text = if (freq >= 1000) {
+ if (freq % 1000 == 0) {
+ "${freq / 1000} kHz"
+ } else {
+ String.format("%.1f kHz", freq / 1000f)
+ }
+ } else {
+ "$freq Hz"
+ }
+ gravity = Gravity.START
+ layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f)
+ }
+ row.addView(freqLabel)
+
+ val initialLevelDb = (savedLevels.getOrNull(i) ?: (manager.getBandLevel(band) ?: 0)) / 100
+ val dbLabel = TextView(requireContext(), null, 0, R.style.LabelSmall).apply {
+ text = formatDb(initialLevelDb)
+ setPadding(12, 0, 0, 0)
+ gravity = Gravity.END
+ layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f)
+ }
+
+ val seekBar = SeekBar(requireContext()).apply {
+ max = maxLevelDb - minLevelDb
+ progress = initialLevelDb - minLevelDb
+ layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 6f)
+ setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+ override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
+ val thisLevelDb = progress + minLevelDb
+ if (fromUser) {
+ manager.setBandLevel(band, (thisLevelDb * 100).toShort())
+ saveBandLevelsToPreferences()
+ }
+ dbLabel.text = formatDb(thisLevelDb)
+ }
+
+ override fun onStartTrackingTouch(seekBar: SeekBar) {}
+ override fun onStopTrackingTouch(seekBar: SeekBar) {}
+ })
+ }
+ bandSeekBars.add(seekBar)
+ row.addView(seekBar)
+ row.addView(dbLabel)
+ eqBandsContainer.addView(row)
+ }
+ }
+
+ private fun resetEqualizer() {
+ val manager = equalizerManager ?: return
+ val bands = manager.getNumberOfBands()
+ val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500)
+ val minLevelDb = bandLevelRange[0] / 100
+ val midLevelDb = 0
+
+ for (i in 0 until bands) {
+ manager.setBandLevel(i.toShort(), (0).toShort())
+ bandSeekBars.getOrNull(i)?.progress = midLevelDb - minLevelDb
+ }
+ Preferences.setEqualizerBandLevels(ShortArray(bands.toInt()))
+ }
+
+ private fun saveBandLevelsToPreferences() {
+ val manager = equalizerManager ?: return
+ val bands = manager.getNumberOfBands()
+ val levels = ShortArray(bands.toInt()) { i -> manager.getBandLevel(i.toShort()) ?: 0 }
+ Preferences.setEqualizerBandLevels(levels)
+ }
+
+ private fun restoreEqualizerPreferences() {
+ val manager = equalizerManager ?: return
+ eqSwitch.isChecked = Preferences.isEqualizerEnabled()
+ updateUiEnabledState(eqSwitch.isChecked)
+
+ val bands = manager.getNumberOfBands()
+ val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500)
+ val minLevelDb = bandLevelRange[0] / 100
+
+ val savedLevels = Preferences.getEqualizerBandLevels(bands)
+ for (i in 0 until bands) {
+ val savedDb = savedLevels[i] / 100
+ manager.setBandLevel(i.toShort(), (savedDb * 100).toShort())
+ bandSeekBars.getOrNull(i)?.progress = savedDb - minLevelDb
+ }
+ }
+
+}
+
+private fun Int.dpToPx(context: Context): Int =
+ (this * context.resources.displayMetrics.density).toInt()
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java
index 4d30ce30..7936b5b3 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java
@@ -9,6 +9,7 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.PopupMenu;
+import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -40,6 +41,7 @@ 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.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter;
import com.cappielloantonio.tempo.ui.adapter.AlbumHorizontalAdapter;
@@ -60,9 +62,12 @@ import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.UIUtil;
import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
+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;
@@ -74,6 +79,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
private FragmentHomeTabMusicBinding bind;
private MainActivity activity;
private HomeViewModel homeViewModel;
+ private PlaybackViewModel playbackViewModel;
private DiscoverSongAdapter discoverSongAdapter;
private SimilarTrackAdapter similarMusicAdapter;
@@ -101,6 +107,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind = FragmentHomeTabMusicBinding.inflate(inflater, container, false);
View view = bind.getRoot();
homeViewModel = new ViewModelProvider(requireActivity()).get(HomeViewModel.class);
+ playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
@@ -113,6 +120,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
initSyncStarredView();
initSyncStarredAlbumsView();
+ initSyncStarredArtistsView();
initDiscoverSongSlideView();
initSimilarSongView();
initArtistRadio();
@@ -138,12 +146,18 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
super.onStart();
initializeMediaBrowser();
+
+ MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
+ observeStarredSongsPlayback();
+ observeTopSongsPlayback();
}
@Override
public void onResume() {
super.onResume();
refreshSharesView();
+ if (topSongAdapter != null) setTopSongsMediaBrowserListenableFuture();
+ if (starredSongAdapter != null) setStarredSongsMediaBrowserListenableFuture();
}
@Override
@@ -265,7 +279,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
}
private void initSyncStarredView() {
- if (Preferences.isStarredSyncEnabled()) {
+ if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
homeViewModel.getAllStarredTracks().observeForever(new Observer>() {
@Override
public void onChanged(List songs) {
@@ -318,32 +332,12 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
private void initSyncStarredAlbumsView() {
if (Preferences.isStarredAlbumsSyncEnabled()) {
- homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observeForever(new Observer>() {
+ homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer>() {
@Override
public void onChanged(List albums) {
- if (albums != null) {
- DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
- List albumsToSync = new ArrayList<>();
- int albumCount = 0;
-
- for (AlbumID3 album : albums) {
- boolean needsSync = false;
- albumCount++;
- albumsToSync.add(album.getName());
- }
-
- if (albumCount > 0) {
- bind.homeSyncStarredAlbumsCard.setVisibility(View.VISIBLE);
- String message = getResources().getQuantityString(
- R.plurals.home_sync_starred_albums_count,
- albumCount,
- albumCount
- );
- bind.homeSyncStarredAlbumsToSync.setText(message);
- }
+ if (albums != null && !albums.isEmpty()) {
+ checkIfAlbumsNeedSync(albums);
}
-
- homeViewModel.getStarredAlbums(getViewLifecycleOwner()).removeObserver(this);
}
});
}
@@ -353,26 +347,157 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
});
bind.homeSyncStarredAlbumsDownload.setOnClickListener(v -> {
- homeViewModel.getAllStarredAlbumSongs().observeForever(new Observer>() {
+ homeViewModel.getAllStarredAlbumSongs().observe(getViewLifecycleOwner(), new Observer>() {
@Override
public void onChanged(List allSongs) {
- if (allSongs != null) {
+ 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++;
}
}
- }
- homeViewModel.getAllStarredAlbumSongs().removeObserver(this);
+ if (songsToDownload > 0) {
+ Toast.makeText(requireContext(),
+ getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
+ Toast.LENGTH_SHORT).show();
+ }
+ }
+
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
}
});
});
}
+ private void checkIfAlbumsNeedSync(List albums) {
+ homeViewModel.getAllStarredAlbumSongs().observe(getViewLifecycleOwner(), new Observer>() {
+ @Override
+ public void onChanged(List allSongs) {
+ if (allSongs != null) {
+ DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
+ int songsToDownload = 0;
+ List 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 (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(),
+ albumsNeedingSync.size()
+ );
+ bind.homeSyncStarredAlbumsToSync.setText(message);
+ } else {
+ bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
+ }
+ }
+ }
+ });
+ }
+
+ private void initSyncStarredArtistsView() {
+ if (Preferences.isStarredArtistsSyncEnabled()) {
+ homeViewModel.getStarredArtists(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer>() {
+ @Override
+ public void onChanged(List artists) {
+ if (artists != null && !artists.isEmpty()) {
+ checkIfArtistsNeedSync(artists);
+ }
+ }
+ });
+ }
+
+ bind.homeSyncStarredArtistsCancel.setOnClickListener(v -> {
+ bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
+ });
+
+ bind.homeSyncStarredArtistsDownload.setOnClickListener(v -> {
+ homeViewModel.getAllStarredArtistSongs().observe(getViewLifecycleOwner(), new Observer>() {
+ @Override
+ public void onChanged(List 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 (songsToDownload > 0) {
+ Toast.makeText(requireContext(),
+ getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
+ Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
+ }
+ });
+ });
+ }
+
+ private void checkIfArtistsNeedSync(List artists) {
+ homeViewModel.getAllStarredArtistSongs().observe(getViewLifecycleOwner(), new Observer>() {
+ @Override
+ public void onChanged(List allSongs) {
+ if (allSongs != null) {
+ DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
+ int songsToDownload = 0;
+ List 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 (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(),
+ artistsNeedingSync.size()
+ );
+ bind.homeSyncStarredArtistsToSync.setText(message);
+ } else {
+ bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
+ }
+ }
+ }
+ });
+ }
+
private void initDiscoverSongSlideView() {
if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_DISCOVERY)) return;
@@ -475,8 +600,10 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.topSongsRecyclerView.setHasFixedSize(true);
- topSongAdapter = new SongHorizontalAdapter(this, true, false, null);
+ topSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.topSongsRecyclerView.setAdapter(topSongAdapter);
+ setTopSongsMediaBrowserListenableFuture();
+ reapplyTopSongsPlayback();
homeViewModel.getChronologySample(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), chronologies -> {
if (chronologies == null || chronologies.isEmpty()) {
if (bind != null) bind.homeGridTracksSector.setVisibility(View.GONE);
@@ -492,6 +619,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
.collect(Collectors.toList());
topSongAdapter.setItems(topSongs);
+ reapplyTopSongsPlayback();
}
});
@@ -513,8 +641,10 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.starredTracksRecyclerView.setHasFixedSize(true);
- starredSongAdapter = new SongHorizontalAdapter(this, true, false, null);
+ starredSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.starredTracksRecyclerView.setAdapter(starredSongAdapter);
+ setStarredSongsMediaBrowserListenableFuture();
+ reapplyStarredSongsPlayback();
homeViewModel.getStarredTracks(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), songs -> {
if (songs == null) {
if (bind != null) bind.starredTracksSector.setVisibility(View.GONE);
@@ -525,6 +655,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.starredTracksRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), UIUtil.getSpanCount(songs.size(), 5), GridLayoutManager.HORIZONTAL, false));
starredSongAdapter.setItems(songs);
+ reapplyStarredSongsPlayback();
}
});
@@ -954,6 +1085,8 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION));
activity.setBottomSheetInPeek(true);
}
+ topSongAdapter.notifyDataSetChanged();
+ starredSongAdapter.notifyDataSetChanged();
}
@Override
@@ -1043,4 +1176,58 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
public void onShareLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.shareBottomSheetDialog, bundle);
}
+
+ private void observeStarredSongsPlayback() {
+ playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
+ if (starredSongAdapter != null) {
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ starredSongAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
+ if (starredSongAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ starredSongAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ }
+
+ private void observeTopSongsPlayback() {
+ playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
+ if (topSongAdapter != null) {
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ topSongAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
+ if (topSongAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ topSongAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ }
+
+ private void reapplyStarredSongsPlayback() {
+ if (starredSongAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ starredSongAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ }
+
+ private void reapplyTopSongsPlayback() {
+ if (topSongAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ topSongAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ }
+
+ private void setTopSongsMediaBrowserListenableFuture() {
+ topSongAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
+ }
+
+ private void setStarredSongsMediaBrowserListenableFuture() {
+ starredSongAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
+ }
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java
index 6841f247..e2bca343 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java
@@ -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)) {
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java
index 99f3c4ca..e3155b56 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java
@@ -1,7 +1,11 @@
package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
import android.os.Bundle;
+import android.os.IBinder;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
@@ -9,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;
@@ -24,22 +29,27 @@ import androidx.media3.common.util.RepeatModeUtil;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.media3.session.SessionToken;
+import androidx.navigation.NavController;
+import androidx.navigation.NavOptions;
import androidx.navigation.fragment.NavHostFragment;
import androidx.viewpager2.widget.ViewPager2;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerControllerBinding;
+import com.cappielloantonio.tempo.service.EqualizerManager;
import com.cappielloantonio.tempo.service.MediaService;
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;
@@ -68,11 +78,19 @@ public class PlayerControllerFragment extends Fragment {
private ImageButton playerOpenQueueButton;
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;
private ListenableFuture mediaBrowserListenableFuture;
+ private MediaService.LocalBinder mediaServiceBinder;
+ private boolean isServiceBound = false;
+
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
activity = (MainActivity) getActivity();
@@ -89,6 +107,7 @@ public class PlayerControllerFragment extends Fragment {
initMediaListenable();
initMediaLabelButton();
initArtistLabelButton();
+ initEqualizerButton();
return view;
}
@@ -126,6 +145,11 @@ public class PlayerControllerFragment extends Fragment {
playerTrackInfo = bind.getRoot().findViewById(R.id.player_info_track);
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();
}
@@ -206,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) {
@@ -246,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);
@@ -426,6 +556,18 @@ public class PlayerControllerFragment extends Fragment {
});
}
+ private void initEqualizerButton() {
+ equalizerButton.setOnClickListener(v -> {
+ NavController navController = NavHostFragment.findNavController(this);
+ NavOptions navOptions = new NavOptions.Builder()
+ .setLaunchSingleTop(true)
+ .setPopUpTo(R.id.equalizerFragment, true)
+ .build();
+ navController.navigate(R.id.equalizerFragment, null, navOptions);
+ if (activity != null) activity.collapseBottomSheetDelayed();
+ });
+ }
+
public void goToControllerPage() {
playerMediaCoverViewPager.setCurrentItem(0, false);
}
@@ -461,4 +603,66 @@ public class PlayerControllerFragment extends Fragment {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_100));
// TODO Resettare lo skip del silenzio
}
-}
\ No newline at end of file
+
+ private final ServiceConnection serviceConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ mediaServiceBinder = (MediaService.LocalBinder) service;
+ isServiceBound = true;
+ checkEqualizerBands();
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ mediaServiceBinder = null;
+ isServiceBound = false;
+ }
+ };
+
+ private void bindMediaService() {
+ Intent intent = new Intent(requireActivity(), MediaService.class);
+ intent.setAction(MediaService.ACTION_BIND_EQUALIZER);
+ requireActivity().bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
+ isServiceBound = true;
+ }
+
+ private void checkEqualizerBands() {
+ if (mediaServiceBinder != null) {
+ EqualizerManager eqManager = mediaServiceBinder.getEqualizerManager();
+ short numBands = eqManager.getNumberOfBands();
+
+ if (equalizerButton != null) {
+ if (numBands == 0) {
+ equalizerButton.setVisibility(View.GONE);
+
+ ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) playerOpenQueueButton.getLayoutParams();
+ params.startToEnd = ConstraintLayout.LayoutParams.UNSET;
+ params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
+ playerOpenQueueButton.setLayoutParams(params);
+ } else {
+ equalizerButton.setVisibility(View.VISIBLE);
+
+ ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) playerOpenQueueButton.getLayoutParams();
+ params.startToStart = ConstraintLayout.LayoutParams.UNSET;
+ params.startToEnd = R.id.player_open_equalizer_button;
+ playerOpenQueueButton.setLayoutParams(params);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ bindMediaService();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ if (isServiceBound) {
+ requireActivity().unbindService(serviceConnection);
+ isServiceBound = false;
+ }
+ }
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java
index 392c5786..2d73847e 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java
@@ -14,6 +14,7 @@ import java.util.ArrayList;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
+import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
@@ -31,6 +32,7 @@ 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.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.google.android.material.snackbar.Snackbar;
@@ -115,10 +117,14 @@ public class PlayerCoverFragment extends Fragment {
playerBottomSheetViewModel.getLiveMedia().observe(getViewLifecycleOwner(), song -> {
if (song != null && bind != null) {
bind.innerButtonTopLeft.setOnClickListener(view -> {
- DownloadUtil.getDownloadTracker(requireContext()).download(
- MappingUtil.mapDownload(song),
- new Download(song)
- );
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).download(
+ MappingUtil.mapDownload(song),
+ new Download(song)
+ );
+ } else {
+ ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
+ }
});
bind.innerButtonTopRight.setOnClickListener(view -> {
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerLyricsFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerLyricsFragment.java
index 7140632f..24a1abcd 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerLyricsFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerLyricsFragment.java
@@ -4,15 +4,16 @@ import android.annotation.SuppressLint;
import android.content.ComponentName;
import android.os.Bundle;
import android.os.Handler;
+import android.text.Layout;
import android.text.Spannable;
import android.text.SpannableString;
-import android.text.Layout;
+import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
-import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
+import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -29,10 +30,10 @@ import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Line;
import com.cappielloantonio.tempo.subsonic.models.LyricsList;
import com.cappielloantonio.tempo.util.MusicUtil;
-import com.cappielloantonio.tempo.util.OpenSubsonicExtensionsUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.google.common.util.concurrent.ListenableFuture;
+import com.google.android.material.button.MaterialButton;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.List;
@@ -48,6 +49,9 @@ public class PlayerLyricsFragment extends Fragment {
private MediaBrowser mediaBrowser;
private Handler syncLyricsHandler;
private Runnable syncLyricsRunnable;
+ private String currentLyrics;
+ private LyricsList currentLyricsList;
+ private String currentDescription;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
@@ -66,6 +70,7 @@ public class PlayerLyricsFragment extends Fragment {
super.onViewCreated(view, savedInstanceState);
initPanelContent();
+ observeDownloadState();
}
@Override
@@ -101,12 +106,26 @@ public class PlayerLyricsFragment extends Fragment {
public void onDestroyView() {
super.onDestroyView();
bind = null;
+ currentLyrics = null;
+ currentLyricsList = null;
+ currentDescription = null;
}
private void initOverlay() {
bind.syncLyricsTapButton.setOnClickListener(view -> {
playerBottomSheetViewModel.changeSyncLyricsState();
});
+
+ bind.downloadLyricsButton.setOnClickListener(view -> {
+ boolean saved = playerBottomSheetViewModel.downloadCurrentLyrics();
+ if (getContext() != null) {
+ Toast.makeText(
+ requireContext(),
+ saved ? R.string.player_lyrics_download_success : R.string.player_lyrics_download_failure,
+ Toast.LENGTH_SHORT
+ ).show();
+ }
+ });
}
private void initializeBrowser() {
@@ -136,50 +155,91 @@ public class PlayerLyricsFragment extends Fragment {
}
private void initPanelContent() {
- if (OpenSubsonicExtensionsUtil.isSongLyricsExtensionAvailable()) {
- playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
- setPanelContent(null, lyricsList);
- });
- } else {
- playerBottomSheetViewModel.getLiveLyrics().observe(getViewLifecycleOwner(), lyrics -> {
- setPanelContent(lyrics, null);
- });
- }
+ playerBottomSheetViewModel.getLiveLyrics().observe(getViewLifecycleOwner(), lyrics -> {
+ currentLyrics = lyrics;
+ updatePanelContent();
+ });
+
+ playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
+ currentLyricsList = lyricsList;
+ updatePanelContent();
+ });
+
+ playerBottomSheetViewModel.getLiveDescription().observe(getViewLifecycleOwner(), description -> {
+ currentDescription = description;
+ updatePanelContent();
+ });
}
- private void setPanelContent(String lyrics, LyricsList lyricsList) {
- playerBottomSheetViewModel.getLiveDescription().observe(getViewLifecycleOwner(), description -> {
+ private void observeDownloadState() {
+ playerBottomSheetViewModel.getLyricsCachedState().observe(getViewLifecycleOwner(), cached -> {
if (bind != null) {
- bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0);
-
- if (lyrics != null && !lyrics.trim().equals("")) {
- bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(lyrics));
- bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
- bind.emptyDescriptionImageView.setVisibility(View.GONE);
- bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
- bind.syncLyricsTapButton.setVisibility(View.GONE);
- } else if (lyricsList != null && lyricsList.getStructuredLyrics() != null) {
- setSyncLirics(lyricsList);
- bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
- bind.emptyDescriptionImageView.setVisibility(View.GONE);
- bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
- bind.syncLyricsTapButton.setVisibility(View.VISIBLE);
- } else if (description != null && !description.trim().equals("")) {
- bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(description));
- bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
- bind.emptyDescriptionImageView.setVisibility(View.GONE);
- bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
- bind.syncLyricsTapButton.setVisibility(View.GONE);
+ MaterialButton downloadButton = (MaterialButton) bind.downloadLyricsButton;
+ if (cached != null && cached) {
+ downloadButton.setIconResource(R.drawable.ic_done);
+ downloadButton.setContentDescription(getString(R.string.player_lyrics_downloaded_content_description));
} else {
- bind.nowPlayingSongLyricsTextView.setVisibility(View.GONE);
- bind.emptyDescriptionImageView.setVisibility(View.VISIBLE);
- bind.titleEmptyDescriptionLabel.setVisibility(View.VISIBLE);
- bind.syncLyricsTapButton.setVisibility(View.GONE);
+ downloadButton.setIconResource(R.drawable.ic_download);
+ downloadButton.setContentDescription(getString(R.string.player_lyrics_download_content_description));
}
}
});
}
+ private void updatePanelContent() {
+ if (bind == null) {
+ return;
+ }
+
+ bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0);
+
+ if (hasStructuredLyrics(currentLyricsList)) {
+ setSyncLirics(currentLyricsList);
+ bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
+ bind.emptyDescriptionImageView.setVisibility(View.GONE);
+ bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
+ bind.syncLyricsTapButton.setVisibility(View.VISIBLE);
+ bind.downloadLyricsButton.setVisibility(View.VISIBLE);
+ bind.downloadLyricsButton.setEnabled(true);
+ } else if (hasText(currentLyrics)) {
+ bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(currentLyrics));
+ bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
+ bind.emptyDescriptionImageView.setVisibility(View.GONE);
+ bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
+ bind.syncLyricsTapButton.setVisibility(View.GONE);
+ bind.downloadLyricsButton.setVisibility(View.VISIBLE);
+ bind.downloadLyricsButton.setEnabled(true);
+ } else if (hasText(currentDescription)) {
+ bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(currentDescription));
+ bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
+ bind.emptyDescriptionImageView.setVisibility(View.GONE);
+ bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
+ bind.syncLyricsTapButton.setVisibility(View.GONE);
+ bind.downloadLyricsButton.setVisibility(View.GONE);
+ bind.downloadLyricsButton.setEnabled(false);
+ } else {
+ bind.nowPlayingSongLyricsTextView.setVisibility(View.GONE);
+ bind.emptyDescriptionImageView.setVisibility(View.VISIBLE);
+ bind.titleEmptyDescriptionLabel.setVisibility(View.VISIBLE);
+ bind.syncLyricsTapButton.setVisibility(View.GONE);
+ bind.downloadLyricsButton.setVisibility(View.GONE);
+ bind.downloadLyricsButton.setEnabled(false);
+ }
+ }
+
+ private boolean hasText(String value) {
+ return value != null && !value.trim().isEmpty();
+ }
+
+ private boolean hasStructuredLyrics(LyricsList lyricsList) {
+ return lyricsList != null
+ && lyricsList.getStructuredLyrics() != null
+ && !lyricsList.getStructuredLyrics().isEmpty()
+ && lyricsList.getStructuredLyrics().get(0) != null
+ && lyricsList.getStructuredLyrics().get(0).getLine() != null
+ && !lyricsList.getStructuredLyrics().get(0).getLine().isEmpty();
+ }
+
@SuppressLint("DefaultLocale")
private void setSyncLirics(LyricsList lyricsList) {
if (lyricsList.getStructuredLyrics() != null && !lyricsList.getStructuredLyrics().isEmpty() && lyricsList.getStructuredLyrics().get(0).getLine() != null) {
@@ -198,28 +258,28 @@ public class PlayerLyricsFragment extends Fragment {
private void defineProgressHandler() {
playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
- if (lyricsList != null) {
-
- if (lyricsList.getStructuredLyrics() != null && lyricsList.getStructuredLyrics().get(0) != null && !lyricsList.getStructuredLyrics().get(0).getSynced()) {
- releaseHandler();
- return;
- }
-
- syncLyricsHandler = new Handler();
- syncLyricsRunnable = () -> {
- if (syncLyricsHandler != null) {
- if (bind != null) {
- displaySyncedLyrics();
- }
-
- syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
- }
- };
-
- syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
- } else {
+ if (!hasStructuredLyrics(lyricsList)) {
releaseHandler();
+ return;
}
+
+ if (!lyricsList.getStructuredLyrics().get(0).getSynced()) {
+ releaseHandler();
+ return;
+ }
+
+ syncLyricsHandler = new Handler();
+ syncLyricsRunnable = () -> {
+ if (syncLyricsHandler != null) {
+ if (bind != null) {
+ displaySyncedLyrics();
+ }
+
+ syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
+ }
+ };
+
+ syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
});
}
@@ -227,7 +287,7 @@ public class PlayerLyricsFragment extends Fragment {
LyricsList lyricsList = playerBottomSheetViewModel.getLiveLyricsList().getValue();
int timestamp = (int) (mediaBrowser.getCurrentPosition());
- if (lyricsList != null && lyricsList.getStructuredLyrics() != null && !lyricsList.getStructuredLyrics().isEmpty() && lyricsList.getStructuredLyrics().get(0).getLine() != null) {
+ if (hasStructuredLyrics(lyricsList)) {
StringBuilder lyricsBuilder = new StringBuilder();
List lines = lyricsList.getStructuredLyrics().get(0).getLine();
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerQueueFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerQueueFragment.java
index f53a2247..06536cd6 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerQueueFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerQueueFragment.java
@@ -23,6 +23,7 @@ import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.adapter.PlayerSongQueueAdapter;
import com.cappielloantonio.tempo.util.Constants;
+import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
@@ -38,6 +39,7 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
private InnerFragmentPlayerQueueBinding bind;
private PlayerBottomSheetViewModel playerBottomSheetViewModel;
+ private PlaybackViewModel playbackViewModel;
private ListenableFuture mediaBrowserListenableFuture;
private PlayerSongQueueAdapter playerSongQueueAdapter;
@@ -48,6 +50,7 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
View view = bind.getRoot();
playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
+ playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
initQueueRecyclerView();
@@ -59,6 +62,9 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
super.onStart();
initializeBrowser();
bindMediaController();
+
+ MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
+ observePlayback();
}
@Override
@@ -110,9 +116,12 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
playerSongQueueAdapter = new PlayerSongQueueAdapter(this);
bind.playerQueueRecyclerView.setAdapter(playerSongQueueAdapter);
+ reapplyPlayback();
+
playerBottomSheetViewModel.getQueueSong().observe(getViewLifecycleOwner(), queue -> {
if (queue != null) {
playerSongQueueAdapter.setItems(queue.stream().map(item -> (Child) item).collect(Collectors.toList()));
+ reapplyPlayback();
}
});
@@ -216,4 +225,27 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
public void onMediaClick(Bundle bundle) {
MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION));
}
+
+ private void observePlayback() {
+ playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
+ if (playerSongQueueAdapter != null) {
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
+ if (playerSongQueueAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ }
+
+ private void reapplyPlayback() {
+ if (playerSongQueueAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java
index 55b46ff2..d4cf6c0f 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java
@@ -37,6 +37,9 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
+import com.cappielloantonio.tempo.util.ExternalAudioWriter;
+import com.cappielloantonio.tempo.util.Preferences;
+import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaylistPageViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@@ -49,6 +52,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
private FragmentPlaylistPageBinding bind;
private MainActivity activity;
private PlaylistPageViewModel playlistPageViewModel;
+ private PlaybackViewModel playbackViewModel;
private SongHorizontalAdapter songHorizontalAdapter;
@@ -94,6 +98,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
bind = FragmentPlaylistPageBinding.inflate(inflater, container, false);
View view = bind.getRoot();
playlistPageViewModel = new ViewModelProvider(requireActivity()).get(PlaylistPageViewModel.class);
+ playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
initAppBar();
@@ -109,6 +114,15 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
super.onStart();
initializeMediaBrowser();
+
+ MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
+ observePlayback();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
}
@Override
@@ -128,7 +142,8 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
if (item.getItemId() == R.id.action_download_playlist) {
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> {
if (isVisible() && getActivity() != null) {
- DownloadUtil.getDownloadTracker(requireContext()).download(
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).download(
MappingUtil.mapDownloads(songs),
songs.stream().map(child -> {
Download toDownload = new Download(child);
@@ -136,7 +151,10 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
toDownload.setPlaylistName(playlistPageViewModel.getPlaylist().getName());
return toDownload;
}).collect(Collectors.toList())
- );
+ );
+ } else {
+ songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
+ }
}
});
return true;
@@ -246,10 +264,15 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.songRecyclerView.setHasFixedSize(true);
- songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
+ songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
+ setMediaBrowserListenableFuture();
+ reapplyPlayback();
- playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> songHorizontalAdapter.setItems(songs));
+ playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> {
+ songHorizontalAdapter.setItems(songs);
+ reapplyPlayback();
+ });
}
private void initializeMediaBrowser() {
@@ -270,4 +293,31 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
public void onMediaLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
}
+
+ private void observePlayback() {
+ playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
+ if (songHorizontalAdapter != null) {
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
+ if (songHorizontalAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ }
+
+ private void reapplyPlayback() {
+ if (songHorizontalAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ }
+
+ private void setMediaBrowserListenableFuture() {
+ songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
+ }
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java
index 8792a98a..14efc149 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java
@@ -4,14 +4,11 @@ import android.content.ComponentName;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
-import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
-import android.view.inputmethod.EditorInfo;
import android.widget.ImageView;
import android.widget.TextView;
-import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -34,6 +31,7 @@ import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter;
import com.cappielloantonio.tempo.ui.adapter.ArtistAdapter;
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants;
+import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.SearchViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@@ -46,6 +44,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
private FragmentSearchBinding bind;
private MainActivity activity;
private SearchViewModel searchViewModel;
+ private PlaybackViewModel playbackViewModel;
private ArtistAdapter artistAdapter;
private AlbumAdapter albumAdapter;
@@ -61,6 +60,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
bind = FragmentSearchBinding.inflate(inflater, container, false);
View view = bind.getRoot();
searchViewModel = new ViewModelProvider(requireActivity()).get(SearchViewModel.class);
+ playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
initSearchResultView();
initSearchView();
@@ -73,6 +73,15 @@ public class SearchFragment extends Fragment implements ClickCallback {
public void onStart() {
super.onStart();
initializeMediaBrowser();
+
+ MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
+ observePlayback();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
}
@Override
@@ -112,7 +121,10 @@ public class SearchFragment extends Fragment implements ClickCallback {
bind.searchResultTracksRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.searchResultTracksRecyclerView.setHasFixedSize(true);
- songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
+ songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
+ setMediaBrowserListenableFuture();
+ reapplyPlayback();
+
bind.searchResultTracksRecyclerView.setAdapter(songHorizontalAdapter);
}
@@ -242,7 +254,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
}
private boolean isQueryValid(String query) {
- return !query.equals("") && query.trim().length() > 2;
+ return !query.equals("") && query.trim().length() > 1;
}
private void inputFocus() {
@@ -260,6 +272,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
@Override
public void onMediaClick(Bundle bundle) {
MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION));
+ songHorizontalAdapter.notifyDataSetChanged();
activity.setBottomSheetInPeek(true);
}
@@ -287,4 +300,31 @@ public class SearchFragment extends Fragment implements ClickCallback {
public void onArtistLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.artistBottomSheetDialog, bundle);
}
+
+ private void observePlayback() {
+ playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
+ if (songHorizontalAdapter != null) {
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
+ if (songHorizontalAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ }
+
+ private void reapplyPlayback() {
+ if (songHorizontalAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ }
+
+ private void setMediaBrowserListenableFuture() {
+ songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
+ }
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java
index e8ddf2b2..2dd556df 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java
@@ -1,13 +1,19 @@
package com.cappielloantonio.tempo.ui.fragment;
+import android.app.Activity;
+import android.content.Context;
+import android.content.ComponentName;
import android.content.Intent;
+import android.content.ServiceConnection;
import android.media.audiofx.AudioEffect;
+import android.net.Uri;
import android.os.Bundle;
-import android.os.Handler;
+import android.os.IBinder;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
+import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
@@ -18,6 +24,9 @@ import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.os.LocaleListCompat;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
+import androidx.navigation.NavController;
+import androidx.navigation.NavOptions;
+import androidx.navigation.fragment.NavHostFragment;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
@@ -28,15 +37,19 @@ import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.helper.ThemeHelper;
import com.cappielloantonio.tempo.interfaces.DialogClickCallback;
import com.cappielloantonio.tempo.interfaces.ScanCallback;
+import com.cappielloantonio.tempo.service.EqualizerManager;
+import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.dialog.DeleteDownloadStorageDialog;
import com.cappielloantonio.tempo.ui.dialog.DownloadStorageDialog;
import com.cappielloantonio.tempo.ui.dialog.StarredSyncDialog;
import com.cappielloantonio.tempo.ui.dialog.StarredAlbumSyncDialog;
+import com.cappielloantonio.tempo.ui.dialog.StarredArtistSyncDialog;
import com.cappielloantonio.tempo.ui.dialog.StreamingCacheStorageDialog;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.UIUtil;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.viewmodel.SettingViewModel;
import java.util.Locale;
@@ -49,15 +62,41 @@ public class SettingsFragment extends PreferenceFragmentCompat {
private MainActivity activity;
private SettingViewModel settingViewModel;
- private ActivityResultLauncher someActivityResultLauncher;
+ private ActivityResultLauncher equalizerResultLauncher;
+ private ActivityResultLauncher directoryPickerLauncher;
+
+ private MediaService.LocalBinder mediaServiceBinder;
+ private boolean isServiceBound = false;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- someActivityResultLauncher = registerForActivityResult(
+ equalizerResultLauncher = registerForActivityResult(
+ new ActivityResultContracts.StartActivityForResult(),
+ result -> {}
+ );
+
+ directoryPickerLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
+ if (result.getResultCode() == Activity.RESULT_OK) {
+ Intent data = result.getData();
+ if (data != null) {
+ Uri uri = data.getData();
+ if (uri != null) {
+ requireContext().getContentResolver().takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ );
+
+ Preferences.setDownloadDirectoryUri(uri.toString());
+ ExternalAudioReader.refreshCache();
+ Toast.makeText(requireContext(), R.string.settings_download_folder_set, Toast.LENGTH_SHORT).show();
+ checkDownloadDirectory();
+ }
+ }
+ }
});
}
@@ -86,9 +125,10 @@ public class SettingsFragment extends PreferenceFragmentCompat {
public void onResume() {
super.onResume();
- checkEqualizer();
+ checkSystemEqualizer();
checkCacheStorage();
checkStorage();
+ checkDownloadDirectory();
setStreamingCacheSize();
setAppLanguage();
@@ -98,10 +138,17 @@ public class SettingsFragment extends PreferenceFragmentCompat {
actionScan();
actionSyncStarredAlbums();
actionSyncStarredTracks();
+ actionSyncStarredArtists();
actionChangeStreamingCacheStorage();
actionChangeDownloadStorage();
+ actionSetDownloadDirectory();
actionDeleteDownloadStorage();
actionKeepScreenOn();
+ actionAutoDownloadLyrics();
+ actionMiniPlayerHeart();
+
+ bindMediaService();
+ actionAppEqualizer();
}
@Override
@@ -124,8 +171,8 @@ public class SettingsFragment extends PreferenceFragmentCompat {
}
}
- private void checkEqualizer() {
- Preference equalizer = findPreference("equalizer");
+ private void checkSystemEqualizer() {
+ Preference equalizer = findPreference("system_equalizer");
if (equalizer == null) return;
@@ -133,7 +180,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
if ((intent.resolveActivity(requireActivity().getPackageManager()) != null)) {
equalizer.setOnPreferenceClickListener(preference -> {
- someActivityResultLauncher.launch(intent);
+ equalizerResultLauncher.launch(intent);
return true;
});
} else {
@@ -150,7 +197,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
if (requireContext().getExternalFilesDirs(null)[1] == null) {
storage.setVisible(false);
} else {
- storage.setSummary(Preferences.getDownloadStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
+ storage.setSummary(Preferences.getStreamingCacheStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
}
} catch (Exception exception) {
storage.setVisible(false);
@@ -166,13 +213,46 @@ public class SettingsFragment extends PreferenceFragmentCompat {
if (requireContext().getExternalFilesDirs(null)[1] == null) {
storage.setVisible(false);
} else {
- storage.setSummary(Preferences.getDownloadStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
+ int pref = Preferences.getDownloadStoragePreference();
+ if (pref == 0) {
+ storage.setSummary(R.string.download_storage_internal_dialog_negative_button);
+ } else if (pref == 1) {
+ storage.setSummary(R.string.download_storage_external_dialog_positive_button);
+ } else {
+ storage.setSummary(R.string.download_storage_directory_dialog_neutral_button);
+ }
}
} catch (Exception exception) {
storage.setVisible(false);
}
}
+ private void checkDownloadDirectory() {
+ Preference storage = findPreference("download_storage");
+ Preference directory = findPreference("set_download_directory");
+
+ if (directory == null) return;
+
+ String current = Preferences.getDownloadDirectoryUri();
+ if (current != null) {
+ if (storage != null) storage.setVisible(false);
+ directory.setVisible(true);
+ directory.setIcon(R.drawable.ic_close);
+ directory.setTitle(R.string.settings_clear_download_folder);
+ directory.setSummary(current);
+ } else {
+ if (storage != null) storage.setVisible(true);
+ if (Preferences.getDownloadStoragePreference() == 2) {
+ directory.setVisible(true);
+ directory.setIcon(R.drawable.ic_folder);
+ directory.setTitle(R.string.settings_set_download_folder);
+ directory.setSummary(R.string.settings_choose_download_folder);
+ } else {
+ directory.setVisible(false);
+ }
+ }
+ }
+
private void setStreamingCacheSize() {
ListPreference streamingCachePreference = findPreference("streaming_cache_size");
@@ -245,7 +325,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
@Override
public void onSuccess(boolean isScanning, long count) {
- findPreference("scan_library").setSummary("Scanning: counting " + count + " tracks");
+ findPreference("scan_library").setSummary(getString(R.string.settings_scan_result, count));
if (isScanning) getScanStatus();
}
});
@@ -281,7 +361,21 @@ public class SettingsFragment extends PreferenceFragmentCompat {
return true;
});
}
-
+
+ private void actionSyncStarredArtists() {
+ findPreference("sync_starred_artists_for_offline_use").setOnPreferenceChangeListener((preference, newValue) -> {
+ if (newValue instanceof Boolean) {
+ if ((Boolean) newValue) {
+ StarredArtistSyncDialog dialog = new StarredArtistSyncDialog(() -> {
+ ((SwitchPreference)preference).setChecked(false);
+ });
+ dialog.show(activity.getSupportFragmentManager(), null);
+ }
+ }
+ return true;
+ });
+ }
+
private void actionChangeStreamingCacheStorage() {
findPreference("streaming_cache_storage").setOnPreferenceClickListener(preference -> {
StreamingCacheStorageDialog dialog = new StreamingCacheStorageDialog(new DialogClickCallback() {
@@ -306,11 +400,19 @@ public class SettingsFragment extends PreferenceFragmentCompat {
@Override
public void onPositiveClick() {
findPreference("download_storage").setSummary(R.string.download_storage_external_dialog_positive_button);
+ checkDownloadDirectory();
}
@Override
public void onNegativeClick() {
findPreference("download_storage").setSummary(R.string.download_storage_internal_dialog_negative_button);
+ checkDownloadDirectory();
+ }
+
+ @Override
+ public void onNeutralClick() {
+ findPreference("download_storage").setSummary(R.string.download_storage_directory_dialog_neutral_button);
+ checkDownloadDirectory();
}
});
dialog.show(activity.getSupportFragmentManager(), null);
@@ -318,6 +420,31 @@ public class SettingsFragment extends PreferenceFragmentCompat {
});
}
+ private void actionSetDownloadDirectory() {
+ Preference pref = findPreference("set_download_directory");
+ if (pref != null) {
+ pref.setOnPreferenceClickListener(preference -> {
+ String current = Preferences.getDownloadDirectoryUri();
+
+ if (current != null) {
+ Preferences.setDownloadDirectoryUri(null);
+ Preferences.setDownloadStoragePreference(0);
+ ExternalAudioReader.refreshCache();
+ Toast.makeText(requireContext(), R.string.settings_download_folder_cleared, Toast.LENGTH_SHORT).show();
+ checkStorage();
+ checkDownloadDirectory();
+ } else {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+ intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
+ | Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ directoryPickerLauncher.launch(intent);
+ }
+ return true;
+ });
+ }
+ }
+
private void actionDeleteDownloadStorage() {
findPreference("delete_download_storage").setOnPreferenceClickListener(preference -> {
DeleteDownloadStorageDialog dialog = new DeleteDownloadStorageDialog();
@@ -326,6 +453,36 @@ public class SettingsFragment extends PreferenceFragmentCompat {
});
}
+ private void actionMiniPlayerHeart() {
+ SwitchPreference preference = findPreference("mini_shuffle_button_visibility");
+ if (preference == null) {
+ return;
+ }
+
+ preference.setChecked(Preferences.showShuffleInsteadOfHeart());
+ preference.setOnPreferenceChangeListener((pref, newValue) -> {
+ if (newValue instanceof Boolean) {
+ Preferences.setShuffleInsteadOfHeart((Boolean) newValue);
+ }
+ return true;
+ });
+ }
+
+ private void actionAutoDownloadLyrics() {
+ SwitchPreference preference = findPreference("auto_download_lyrics");
+ if (preference == null) {
+ return;
+ }
+
+ preference.setChecked(Preferences.isAutoDownloadLyricsEnabled());
+ preference.setOnPreferenceChangeListener((pref, newValue) -> {
+ if (newValue instanceof Boolean) {
+ Preferences.setAutoDownloadLyricsEnabled((Boolean) newValue);
+ }
+ return true;
+ });
+ }
+
private void getScanStatus() {
settingViewModel.getScanStatus(new ScanCallback() {
@Override
@@ -335,7 +492,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
@Override
public void onSuccess(boolean isScanning, long count) {
- findPreference("scan_library").setSummary("Scanning: counting " + count + " tracks");
+ findPreference("scan_library").setSummary(getString(R.string.settings_scan_result, count));
if (isScanning) getScanStatus();
}
});
@@ -353,4 +510,63 @@ public class SettingsFragment extends PreferenceFragmentCompat {
return true;
});
}
+
+ private final ServiceConnection serviceConnection = new ServiceConnection() {
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ mediaServiceBinder = (MediaService.LocalBinder) service;
+ isServiceBound = true;
+ checkEqualizerBands();
+ }
+
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ mediaServiceBinder = null;
+ isServiceBound = false;
+ }
+ };
+
+ private void bindMediaService() {
+ Intent intent = new Intent(requireActivity(), MediaService.class);
+ intent.setAction(MediaService.ACTION_BIND_EQUALIZER);
+ requireActivity().bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
+ isServiceBound = true;
+ }
+
+ private void checkEqualizerBands() {
+ if (mediaServiceBinder != null) {
+ EqualizerManager eqManager = mediaServiceBinder.getEqualizerManager();
+ short numBands = eqManager.getNumberOfBands();
+ Preference appEqualizer = findPreference("app_equalizer");
+ if (appEqualizer != null) {
+ appEqualizer.setVisible(numBands > 0);
+ }
+ }
+ }
+
+ private void actionAppEqualizer() {
+ Preference appEqualizer = findPreference("app_equalizer");
+ if (appEqualizer != null) {
+ appEqualizer.setOnPreferenceClickListener(preference -> {
+ NavController navController = NavHostFragment.findNavController(this);
+ NavOptions navOptions = new NavOptions.Builder()
+ .setLaunchSingleTop(true)
+ .setPopUpTo(R.id.equalizerFragment, true)
+ .build();
+ activity.setBottomNavigationBarVisibility(true);
+ activity.setBottomSheetVisibility(true);
+ navController.navigate(R.id.equalizerFragment, null, navOptions);
+ return true;
+ });
+ }
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ if (isServiceBound) {
+ requireActivity().unbindService(serviceConnection);
+ isServiceBound = false;
+ }
+ }
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SongListPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SongListPageFragment.java
index fcaeec84..fe46edae 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SongListPageFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SongListPageFragment.java
@@ -36,6 +36,7 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants;
+import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.SongListPageViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@@ -49,6 +50,7 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
private FragmentSongListPageBinding bind;
private MainActivity activity;
private SongListPageViewModel songListPageViewModel;
+ private PlaybackViewModel playbackViewModel;
private SongHorizontalAdapter songHorizontalAdapter;
@@ -69,6 +71,7 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
bind = FragmentSongListPageBinding.inflate(inflater, container, false);
View view = bind.getRoot();
songListPageViewModel = new ViewModelProvider(requireActivity()).get(SongListPageViewModel.class);
+ playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
initAppBar();
@@ -82,6 +85,15 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
public void onStart() {
super.onStart();
initializeMediaBrowser();
+
+ MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
+ observePlayback();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ setMediaBrowserListenableFuture();
}
@Override
@@ -189,11 +201,14 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
bind.songListRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.songListRecyclerView.setHasFixedSize(true);
- songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
+ songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.songListRecyclerView.setAdapter(songHorizontalAdapter);
+ setMediaBrowserListenableFuture();
+ reapplyPlayback();
songListPageViewModel.getSongList().observe(getViewLifecycleOwner(), songs -> {
isLoading = false;
songHorizontalAdapter.setItems(songs);
+ reapplyPlayback();
setSongListPageSubtitle(songs);
});
@@ -325,4 +340,31 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
public void onMediaLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
}
+
+ private void observePlayback() {
+ playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
+ if (songHorizontalAdapter != null) {
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
+ if (songHorizontalAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ });
+ }
+
+ private void reapplyPlayback() {
+ if (songHorizontalAdapter != null) {
+ String id = playbackViewModel.getCurrentSongId().getValue();
+ Boolean playing = playbackViewModel.getIsPlaying().getValue();
+ songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
+ }
+ }
+
+ private void setMediaBrowserListenableFuture() {
+ songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java
index f7b742e4..a6167eed 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java
@@ -13,6 +13,7 @@ import android.widget.TextView;
import android.widget.Toast;
import android.widget.ToggleButton;
+import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.MediaItem;
@@ -37,6 +38,8 @@ import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
+import com.cappielloantonio.tempo.util.ExternalAudioWriter;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.viewmodel.AlbumBottomSheetViewModel;
import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
@@ -54,6 +57,10 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
private AlbumBottomSheetViewModel albumBottomSheetViewModel;
private AlbumID3 album;
+ private TextView removeAllTextView;
+ private List currentAlbumTracks = Collections.emptyList();
+ private List currentAlbumMediaItems = Collections.emptyList();
+
private ListenableFuture mediaBrowserListenableFuture;
@Nullable
@@ -72,6 +79,12 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
return view;
}
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateRemoveAllVisibility);
+ }
+
@Override
public void onStart() {
super.onStart();
@@ -163,7 +176,11 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
List downloads = songs.stream().map(Download::new).collect(Collectors.toList());
downloadAll.setOnClickListener(v -> {
- DownloadUtil.getDownloadTracker(requireContext()).download(mediaItems, downloads);
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).download(mediaItems, downloads);
+ } else {
+ songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
+ }
dismissBottomSheet();
});
});
@@ -182,19 +199,23 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
});
});
- TextView removeAll = view.findViewById(R.id.remove_all_text_view);
+ removeAllTextView = view.findViewById(R.id.remove_all_text_view);
albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> {
- List mediaItems = MappingUtil.mapDownloads(songs);
- List downloads = songs.stream().map(Download::new).collect(Collectors.toList());
+ currentAlbumTracks = songs != null ? songs : Collections.emptyList();
+ currentAlbumMediaItems = MappingUtil.mapDownloads(currentAlbumTracks);
- removeAll.setOnClickListener(v -> {
- DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
+ removeAllTextView.setOnClickListener(v -> {
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ List downloads = currentAlbumTracks.stream().map(Download::new).collect(Collectors.toList());
+ DownloadUtil.getDownloadTracker(requireContext()).remove(currentAlbumMediaItems, downloads);
+ } else {
+ currentAlbumTracks.forEach(ExternalAudioReader::delete);
+ }
dismissBottomSheet();
});
+ updateRemoveAllVisibility();
});
- initDownloadUI(removeAll);
-
TextView goToArtist = view.findViewById(R.id.go_to_artist_text_view);
goToArtist.setOnClickListener(v -> albumBottomSheetViewModel.getArtist().observe(getViewLifecycleOwner(), artist -> {
if (artist != null) {
@@ -234,14 +255,29 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
dismiss();
}
- private void initDownloadUI(TextView removeAll) {
- albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> {
- List mediaItems = MappingUtil.mapDownloads(songs);
+ private void updateRemoveAllVisibility() {
+ if (removeAllTextView == null) {
+ return;
+ }
- if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) {
- removeAll.setVisibility(View.VISIBLE);
+ if (currentAlbumTracks == null || currentAlbumTracks.isEmpty()) {
+ removeAllTextView.setVisibility(View.GONE);
+ return;
+ }
+
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ List mediaItems = currentAlbumMediaItems;
+ if (mediaItems == null || mediaItems.isEmpty()) {
+ removeAllTextView.setVisibility(View.GONE);
+ } else if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) {
+ removeAllTextView.setVisibility(View.VISIBLE);
+ } else {
+ removeAllTextView.setVisibility(View.GONE);
}
- });
+ } else {
+ boolean hasLocal = currentAlbumTracks.stream().anyMatch(song -> ExternalAudioReader.getUri(song) != null);
+ removeAllTextView.setVisibility(hasLocal ? View.VISIBLE : View.GONE);
+ }
}
private void initializeMediaBrowser() {
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java
index f3c9d490..78fc943e 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java
@@ -66,7 +66,7 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement
super.onStop();
}
- // TODO Utilizzare il viewmodel come tramite ed evitare le chiamate dirette
+ // TODO Use the viewmodel as a conduit and avoid direct calls
private void init(View view) {
ImageView coverArtist = view.findViewById(R.id.artist_cover_image_view);
CustomGlideRequest.Builder
@@ -81,7 +81,7 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
favoriteToggle.setChecked(artistBottomSheetViewModel.getArtist().getStarred() != null);
favoriteToggle.setOnClickListener(v -> {
- artistBottomSheetViewModel.setFavorite();
+ artistBottomSheetViewModel.setFavorite(requireContext());
});
TextView playRadio = view.findViewById(R.id.play_radio_text_view);
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java
index ee339342..4a720640 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java
@@ -25,6 +25,8 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
+import com.cappielloantonio.tempo.util.Preferences;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import com.google.common.util.concurrent.ListenableFuture;
@@ -117,10 +119,13 @@ public class DownloadedBottomSheetDialog extends BottomSheetDialogFragment imple
TextView removeAll = view.findViewById(R.id.remove_all_text_view);
removeAll.setOnClickListener(v -> {
- List mediaItems = MappingUtil.mapDownloads(songs);
- List downloads = songs.stream().map(Download::new).collect(Collectors.toList());
-
- DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ List mediaItems = MappingUtil.mapDownloads(songs);
+ List downloads = songs.stream().map(Download::new).collect(Collectors.toList());
+ DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
+ } else {
+ songs.forEach(ExternalAudioReader::delete);
+ }
dismissBottomSheet();
});
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java
index 0d15ad2f..39ba4394 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java
@@ -13,6 +13,7 @@ import android.widget.TextView;
import android.widget.Toast;
import android.widget.ToggleButton;
+import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
@@ -29,16 +30,24 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
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.ExternalAudioReader;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
+import com.google.android.material.chip.Chip;
+import com.google.android.material.chip.ChipGroup;
import com.google.common.util.concurrent.ListenableFuture;
+import android.content.Intent;
+import androidx.media3.common.MediaItem;
+import com.cappielloantonio.tempo.util.ExternalAudioWriter;
+
import java.util.ArrayList;
import java.util.Collections;
@@ -48,6 +57,16 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
private SongBottomSheetViewModel songBottomSheetViewModel;
private Child song;
+ private TextView downloadButton;
+ private TextView removeButton;
+ private ChipGroup assetLinkChipGroup;
+ private Chip songLinkChip;
+ private Chip albumLinkChip;
+ private Chip artistLinkChip;
+ private AssetLinkUtil.AssetLink currentSongLink;
+ private AssetLinkUtil.AssetLink currentAlbumLink;
+ private AssetLinkUtil.AssetLink currentArtistLink;
+
private ListenableFuture mediaBrowserListenableFuture;
@Nullable
@@ -66,6 +85,12 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
return view;
}
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateDownloadButtons);
+ }
+
@Override
public void onStart() {
super.onStart();
@@ -94,6 +119,11 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
TextView artistSong = view.findViewById(R.id.song_artist_text_view);
artistSong.setText(songBottomSheetViewModel.getSong().getArtist());
+ initAssetLinkChips(view);
+ bindAssetLinkView(coverSong, currentSongLink);
+ bindAssetLinkView(titleSong, currentSongLink);
+ bindAssetLinkView(artistSong, currentArtistLink != null ? currentArtistLink : currentSongLink);
+
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
favoriteToggle.setChecked(songBottomSheetViewModel.getSong().getStarred() != null);
favoriteToggle.setOnClickListener(v -> {
@@ -157,25 +187,33 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
dismissBottomSheet();
});
- TextView download = view.findViewById(R.id.download_text_view);
- download.setOnClickListener(v -> {
- DownloadUtil.getDownloadTracker(requireContext()).download(
- MappingUtil.mapDownload(song),
- new Download(song)
- );
+ downloadButton = view.findViewById(R.id.download_text_view);
+ downloadButton.setOnClickListener(v -> {
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).download(
+ MappingUtil.mapDownload(song),
+ new Download(song)
+ );
+ } else {
+ ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
+ }
dismissBottomSheet();
});
- TextView remove = view.findViewById(R.id.remove_text_view);
- remove.setOnClickListener(v -> {
- DownloadUtil.getDownloadTracker(requireContext()).remove(
- MappingUtil.mapDownload(song),
- new Download(song)
- );
+ removeButton = view.findViewById(R.id.remove_text_view);
+ removeButton.setOnClickListener(v -> {
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).remove(
+ MappingUtil.mapDownload(song),
+ new Download(song)
+ );
+ } else {
+ ExternalAudioReader.delete(song);
+ }
dismissBottomSheet();
});
- initDownloadUI(download, remove);
+ updateDownloadButtons();
TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view);
addToPlaylist.setOnClickListener(v -> {
@@ -243,13 +281,109 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
dismiss();
}
- private void initDownloadUI(TextView download, TextView remove) {
- if (DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId())) {
- remove.setVisibility(View.VISIBLE);
- } else {
- download.setVisibility(View.VISIBLE);
- remove.setVisibility(View.GONE);
+ private void updateDownloadButtons() {
+ if (downloadButton == null || removeButton == null) {
+ return;
}
+
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ boolean downloaded = DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId());
+ downloadButton.setVisibility(downloaded ? View.GONE : View.VISIBLE);
+ removeButton.setVisibility(downloaded ? View.VISIBLE : View.GONE);
+ } else {
+ boolean hasLocal = ExternalAudioReader.getUri(song) != null;
+ downloadButton.setVisibility(hasLocal ? View.GONE : View.VISIBLE);
+ removeButton.setVisibility(hasLocal ? View.VISIBLE : View.GONE);
+ }
+ }
+
+ private void initAssetLinkChips(View root) {
+ assetLinkChipGroup = root.findViewById(R.id.asset_link_chip_group);
+ songLinkChip = root.findViewById(R.id.asset_link_song_chip);
+ albumLinkChip = root.findViewById(R.id.asset_link_album_chip);
+ artistLinkChip = root.findViewById(R.id.asset_link_artist_chip);
+
+ currentSongLink = bindAssetLinkChip(songLinkChip, AssetLinkUtil.TYPE_SONG, song.getId());
+ currentAlbumLink = bindAssetLinkChip(albumLinkChip, AssetLinkUtil.TYPE_ALBUM, song.getAlbumId());
+ currentArtistLink = bindAssetLinkChip(artistLinkChip, AssetLinkUtil.TYPE_ARTIST, song.getArtistId());
+ syncAssetLinkGroupVisibility();
+ }
+
+ private AssetLinkUtil.AssetLink bindAssetLinkChip(@Nullable Chip chip, String type, @Nullable String id) {
+ if (chip == null) return null;
+ if (id == null || id.isEmpty()) {
+ 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) {
+ ((MainActivity) requireActivity()).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(@Nullable Chip chip) {
+ if (chip == null) return;
+ chip.setVisibility(View.GONE);
+ chip.setText("");
+ chip.setOnClickListener(null);
+ chip.setOnLongClickListener(null);
+ }
+
+ 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 bindAssetLinkView(@Nullable View view, @Nullable 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 -> ((MainActivity) requireActivity()).openAssetLink(assetLink, !AssetLinkUtil.TYPE_SONG.equals(assetLink.type)));
+ 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 initializeMediaBrowser() {
@@ -263,4 +397,4 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
private void refreshShares() {
homeViewModel.refreshShares(requireActivity());
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkNavigator.java b/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkNavigator.java
new file mode 100644
index 00000000..9d3ba966
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkNavigator.java
@@ -0,0 +1,188 @@
+package com.cappielloantonio.tempo.util;
+
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.Observer;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.navigation.NavController;
+import androidx.navigation.NavOptions;
+
+import com.cappielloantonio.tempo.BuildConfig;
+import com.cappielloantonio.tempo.R;
+import com.cappielloantonio.tempo.repository.AlbumRepository;
+import com.cappielloantonio.tempo.repository.ArtistRepository;
+import com.cappielloantonio.tempo.repository.PlaylistRepository;
+import com.cappielloantonio.tempo.repository.SongRepository;
+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.Playlist;
+import com.cappielloantonio.tempo.subsonic.models.Genre;
+import com.cappielloantonio.tempo.ui.activity.MainActivity;
+import com.cappielloantonio.tempo.ui.fragment.bottomsheetdialog.SongBottomSheetDialog;
+import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel;
+
+public final class AssetLinkNavigator {
+ private final MainActivity activity;
+ private final SongRepository songRepository = new SongRepository();
+ private final AlbumRepository albumRepository = new AlbumRepository();
+ private final ArtistRepository artistRepository = new ArtistRepository();
+ private final PlaylistRepository playlistRepository = new PlaylistRepository();
+
+ public AssetLinkNavigator(@NonNull MainActivity activity) {
+ this.activity = activity;
+ }
+
+ public void open(@Nullable AssetLinkUtil.AssetLink assetLink) {
+ if (assetLink == null) {
+ return;
+ }
+ switch (assetLink.type) {
+ case AssetLinkUtil.TYPE_SONG:
+ openSong(assetLink.id);
+ break;
+ case AssetLinkUtil.TYPE_ALBUM:
+ openAlbum(assetLink.id);
+ break;
+ case AssetLinkUtil.TYPE_ARTIST:
+ openArtist(assetLink.id);
+ break;
+ case AssetLinkUtil.TYPE_PLAYLIST:
+ openPlaylist(assetLink.id);
+ break;
+ case AssetLinkUtil.TYPE_GENRE:
+ openGenre(assetLink.id);
+ break;
+ case AssetLinkUtil.TYPE_YEAR:
+ openYear(assetLink.id);
+ break;
+ default:
+ Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show();
+ break;
+ }
+ }
+
+ private void openSong(@NonNull String id) {
+ MutableLiveData liveData = songRepository.getSong(id);
+ Observer observer = new Observer() {
+ @Override
+ public void onChanged(Child child) {
+ liveData.removeObserver(this);
+ if (child == null) {
+ Toast.makeText(activity, R.string.asset_link_error_song, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ SongBottomSheetViewModel viewModel = new ViewModelProvider(activity).get(SongBottomSheetViewModel.class);
+ viewModel.setSong(child);
+ SongBottomSheetDialog dialog = new SongBottomSheetDialog();
+ Bundle args = new Bundle();
+ args.putParcelable(Constants.TRACK_OBJECT, child);
+ dialog.setArguments(args);
+ dialog.show(activity.getSupportFragmentManager(), null);
+ }
+ };
+ liveData.observe(activity, observer);
+ }
+
+ private void openAlbum(@NonNull String id) {
+ MutableLiveData liveData = albumRepository.getAlbum(id);
+ Observer observer = new Observer() {
+ @Override
+ public void onChanged(AlbumID3 album) {
+ liveData.removeObserver(this);
+ if (album == null) {
+ Toast.makeText(activity, R.string.asset_link_error_album, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ Bundle args = new Bundle();
+ args.putParcelable(Constants.ALBUM_OBJECT, album);
+ navigateSafely(R.id.albumPageFragment, args);
+ }
+ };
+ liveData.observe(activity, observer);
+ }
+
+ private void openArtist(@NonNull String id) {
+ MutableLiveData liveData = artistRepository.getArtist(id);
+ Observer observer = new Observer() {
+ @Override
+ public void onChanged(ArtistID3 artist) {
+ liveData.removeObserver(this);
+ if (artist == null) {
+ Toast.makeText(activity, R.string.asset_link_error_artist, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ Bundle args = new Bundle();
+ args.putParcelable(Constants.ARTIST_OBJECT, artist);
+ navigateSafely(R.id.artistPageFragment, args);
+ }
+ };
+ liveData.observe(activity, observer);
+ }
+
+ private void openPlaylist(@NonNull String id) {
+ MutableLiveData liveData = playlistRepository.getPlaylist(id);
+ Observer observer = new Observer() {
+ @Override
+ public void onChanged(Playlist playlist) {
+ liveData.removeObserver(this);
+ if (playlist == null) {
+ Toast.makeText(activity, R.string.asset_link_error_playlist, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ Bundle args = new Bundle();
+ args.putParcelable(Constants.PLAYLIST_OBJECT, playlist);
+ navigateSafely(R.id.playlistPageFragment, args);
+ }
+ };
+ liveData.observe(activity, observer);
+ }
+
+ private void openGenre(@NonNull String genreName) {
+ String trimmed = genreName.trim();
+ if (trimmed.isEmpty()) {
+ Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ Genre genre = new Genre();
+ genre.setGenre(trimmed);
+ genre.setSongCount(0);
+ genre.setAlbumCount(0);
+ Bundle args = new Bundle();
+ args.putParcelable(Constants.GENRE_OBJECT, genre);
+ args.putString(Constants.MEDIA_BY_GENRE, Constants.MEDIA_BY_GENRE);
+ navigateSafely(R.id.songListPageFragment, args);
+ }
+
+ private void openYear(@NonNull String yearValue) {
+ try {
+ int year = Integer.parseInt(yearValue.trim());
+ Bundle args = new Bundle();
+ args.putInt("year_object", year);
+ args.putString(Constants.MEDIA_BY_YEAR, Constants.MEDIA_BY_YEAR);
+ navigateSafely(R.id.songListPageFragment, args);
+ } catch (NumberFormatException ex) {
+ Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ private void navigateSafely(int destinationId, @Nullable Bundle args) {
+ activity.runOnUiThread(() -> {
+ NavController navController = activity.navController;
+ if (navController == null) {
+ return;
+ }
+ if (navController.getCurrentDestination() != null
+ && navController.getCurrentDestination().getId() == destinationId) {
+ navController.navigate(destinationId, args, new NavOptions.Builder().setLaunchSingleTop(true).build());
+ } else {
+ navController.navigate(destinationId, args);
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkUtil.java
new file mode 100644
index 00000000..1609a88a
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkUtil.java
@@ -0,0 +1,188 @@
+package com.cappielloantonio.tempo.util;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+import androidx.core.content.ContextCompat;
+
+import com.cappielloantonio.tempo.R;
+
+import java.util.Objects;
+
+import com.google.android.material.color.MaterialColors;
+
+public final class AssetLinkUtil {
+ public static final String SCHEME = "tempo";
+ public static final String HOST_ASSET = "asset";
+
+ public static final String TYPE_SONG = "song";
+ public static final String TYPE_ALBUM = "album";
+ public static final String TYPE_ARTIST = "artist";
+ public static final String TYPE_PLAYLIST = "playlist";
+ public static final String TYPE_GENRE = "genre";
+ public static final String TYPE_YEAR = "year";
+
+ private AssetLinkUtil() {
+ }
+
+ @Nullable
+ public static AssetLink parse(@Nullable Intent intent) {
+ if (intent == null) return null;
+ return parse(intent.getData());
+ }
+
+ @Nullable
+ public static AssetLink parse(@Nullable Uri uri) {
+ if (uri == null) {
+ return null;
+ }
+
+ if (!SCHEME.equalsIgnoreCase(uri.getScheme())) {
+ return null;
+ }
+
+ String host = uri.getHost();
+ if (!HOST_ASSET.equalsIgnoreCase(host)) {
+ return null;
+ }
+
+ if (uri.getPathSegments().size() < 2) {
+ return null;
+ }
+
+ String type = uri.getPathSegments().get(0);
+ String id = uri.getPathSegments().get(1);
+ if (TextUtils.isEmpty(type) || TextUtils.isEmpty(id)) {
+ return null;
+ }
+
+ if (!isSupportedType(type)) {
+ return null;
+ }
+
+ return new AssetLink(type, id, uri);
+ }
+
+ public static boolean isSupportedType(@Nullable String type) {
+ if (type == null) return false;
+ switch (type) {
+ case TYPE_SONG:
+ case TYPE_ALBUM:
+ case TYPE_ARTIST:
+ case TYPE_PLAYLIST:
+ case TYPE_GENRE:
+ case TYPE_YEAR:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ @NonNull
+ public static Uri buildUri(@NonNull String type, @NonNull String id) {
+ return new Uri.Builder()
+ .scheme(SCHEME)
+ .authority(HOST_ASSET)
+ .appendPath(type)
+ .appendPath(id)
+ .build();
+ }
+
+ @Nullable
+ public static String buildLink(@Nullable String type, @Nullable String id) {
+ if (TextUtils.isEmpty(type) || TextUtils.isEmpty(id) || !isSupportedType(type)) {
+ return null;
+ }
+ return buildUri(Objects.requireNonNull(type), Objects.requireNonNull(id)).toString();
+ }
+
+ @Nullable
+ public static AssetLink buildAssetLink(@Nullable String type, @Nullable String id) {
+ String link = buildLink(type, id);
+ return parseLinkString(link);
+ }
+
+ @Nullable
+ public static AssetLink parseLinkString(@Nullable String link) {
+ if (TextUtils.isEmpty(link)) {
+ return null;
+ }
+ return parse(Uri.parse(link));
+ }
+
+ public static void copyToClipboard(@NonNull Context context, @NonNull AssetLink assetLink) {
+ ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+ if (clipboardManager == null) {
+ return;
+ }
+ ClipData clipData = ClipData.newPlainText(context.getString(R.string.asset_link_clipboard_label), assetLink.uri.toString());
+ clipboardManager.setPrimaryClip(clipData);
+ }
+
+ @StringRes
+ public static int getLabelRes(@NonNull String type) {
+ switch (type) {
+ case TYPE_SONG:
+ return R.string.asset_link_label_song;
+ case TYPE_ALBUM:
+ return R.string.asset_link_label_album;
+ case TYPE_ARTIST:
+ return R.string.asset_link_label_artist;
+ case TYPE_PLAYLIST:
+ return R.string.asset_link_label_playlist;
+ case TYPE_GENRE:
+ return R.string.asset_link_label_genre;
+ case TYPE_YEAR:
+ return R.string.asset_link_label_year;
+ default:
+ return R.string.asset_link_label_unknown;
+ }
+ }
+
+ public static void applyLinkAppearance(@NonNull View view) {
+ if (view instanceof TextView) {
+ TextView textView = (TextView) view;
+ if (textView.getTag(R.id.tag_link_original_color) == null) {
+ textView.setTag(R.id.tag_link_original_color, textView.getCurrentTextColor());
+ }
+ int accent = MaterialColors.getColor(view, com.google.android.material.R.attr.colorPrimary,
+ ContextCompat.getColor(view.getContext(), android.R.color.holo_blue_light));
+ textView.setTextColor(accent);
+ }
+ }
+
+ public static void clearLinkAppearance(@NonNull View view) {
+ if (view instanceof TextView) {
+ TextView textView = (TextView) view;
+ Object original = textView.getTag(R.id.tag_link_original_color);
+ if (original instanceof Integer) {
+ textView.setTextColor((Integer) original);
+ } else {
+ int defaultColor = MaterialColors.getColor(view, com.google.android.material.R.attr.colorOnSurface,
+ ContextCompat.getColor(view.getContext(), android.R.color.primary_text_light));
+ textView.setTextColor(defaultColor);
+ }
+ }
+ }
+
+ public static final class AssetLink {
+ public final String type;
+ public final String id;
+ public final Uri uri;
+
+ AssetLink(@NonNull String type, @NonNull String id, @NonNull Uri uri) {
+ this.type = type;
+ this.id = id;
+ this.uri = uri;
+ }
+ }
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt
index da8862df..bf2a1a72 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt
@@ -85,6 +85,13 @@ object Constants {
const val MEDIA_LEAST_RECENTLY_STARRED = "MEDIA_LEAST_RECENTLY_STARRED"
const val DOWNLOAD_URI = "rest/download"
+ const val ACTION_PLAY_EXTERNAL_DOWNLOAD = "com.cappielloantonio.tempo.action.PLAY_EXTERNAL_DOWNLOAD"
+ const val EXTRA_DOWNLOAD_URI = "EXTRA_DOWNLOAD_URI"
+ const val EXTRA_DOWNLOAD_MEDIA_ID = "EXTRA_DOWNLOAD_MEDIA_ID"
+ const val EXTRA_DOWNLOAD_TITLE = "EXTRA_DOWNLOAD_TITLE"
+ const val EXTRA_DOWNLOAD_ARTIST = "EXTRA_DOWNLOAD_ARTIST"
+ const val EXTRA_DOWNLOAD_ALBUM = "EXTRA_DOWNLOAD_ALBUM"
+ const val EXTRA_DOWNLOAD_DURATION = "EXTRA_DOWNLOAD_DURATION"
const val DOWNLOAD_TYPE_TRACK = "download_type_track"
const val DOWNLOAD_TYPE_ALBUM = "download_type_album"
@@ -116,4 +123,13 @@ object Constants {
const val HOME_SECTOR_RECENTLY_ADDED = "HOME_SECTOR_RECENTLY_ADDED"
const val HOME_SECTOR_PINNED_PLAYLISTS = "HOME_SECTOR_PINNED_PLAYLISTS"
const val HOME_SECTOR_SHARED = "HOME_SECTOR_SHARED"
+
+ const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON = "android.media3.session.demo.SHUFFLE_ON"
+ const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF = "android.media3.session.demo.SHUFFLE_OFF"
+ const val CUSTOM_COMMAND_TOGGLE_HEART_ON = "android.media3.session.demo.HEART_ON"
+ const val CUSTOM_COMMAND_TOGGLE_HEART_OFF = "android.media3.session.demo.HEART_OFF"
+ const val CUSTOM_COMMAND_TOGGLE_HEART_LOADING = "android.media3.session.demo.HEART_LOADING"
+ const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF = "android.media3.session.demo.REPEAT_OFF"
+ const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE = "android.media3.session.demo.REPEAT_ONE"
+ const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL = "android.media3.session.demo.REPEAT_ALL"
}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java
index a8cafc4a..6df73eb6 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java
@@ -78,32 +78,26 @@ public final class DownloadUtil {
return httpDataSourceFactory;
}
- public static synchronized DataSource.Factory getDataSourceFactory(Context context) {
- if (dataSourceFactory == null) {
- context = context.getApplicationContext();
+ public static synchronized DataSource.Factory getUpstreamDataSourceFactory(Context context) {
+ DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory());
+ dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
+ return dataSourceFactory;
+ }
- DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory());
-
- if (Preferences.getStreamingCacheSize() > 0) {
- CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory()
- .setCache(getStreamingCache(context))
- .setUpstreamDataSourceFactory(upstreamFactory);
-
- ResolvingDataSource.Factory resolvingFactory = new ResolvingDataSource.Factory(
- new StreamingCacheDataSource.Factory(streamCacheFactory),
- dataSpec -> {
- DataSpec.Builder builder = dataSpec.buildUpon();
- builder.setFlags(dataSpec.flags & ~DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN);
- return builder.build();
- }
- );
-
- dataSourceFactory = buildReadOnlyCacheDataSource(resolvingFactory, getDownloadCache(context));
- } else {
- dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
- }
- }
+ public static synchronized DataSource.Factory getCacheDataSourceFactory(Context context) {
+ CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory()
+ .setCache(getStreamingCache(context))
+ .setUpstreamDataSourceFactory(getUpstreamDataSourceFactory(context));
+ ResolvingDataSource.Factory resolvingFactory = new ResolvingDataSource.Factory(
+ new StreamingCacheDataSource.Factory(streamCacheFactory),
+ dataSpec -> {
+ DataSpec.Builder builder = dataSpec.buildUpon();
+ builder.setFlags(dataSpec.flags & ~DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN);
+ return builder.build();
+ }
+ );
+ dataSourceFactory = buildReadOnlyCacheDataSource(resolvingFactory, getDownloadCache(context));
return dataSourceFactory;
}
@@ -193,19 +187,21 @@ public final class DownloadUtil {
private static synchronized File getDownloadDirectory(Context context) {
if (downloadDirectory == null) {
- if (Preferences.getDownloadStoragePreference() == 0) {
+ int pref = Preferences.getDownloadStoragePreference();
+ if (pref == 0) {
downloadDirectory = context.getExternalFilesDirs(null)[0];
if (downloadDirectory == null) {
downloadDirectory = context.getFilesDir();
}
- } else {
+ } else if (pref == 1) {
try {
downloadDirectory = context.getExternalFilesDirs(null)[1];
} catch (Exception exception) {
downloadDirectory = context.getExternalFilesDirs(null)[0];
Preferences.setDownloadStoragePreference(0);
}
-
+ } else {
+ downloadDirectory = context.getExternalFilesDirs(null)[0];
}
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt b/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt
new file mode 100644
index 00000000..31dc172a
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt
@@ -0,0 +1,69 @@
+package com.cappielloantonio.tempo.util
+
+import android.content.Context
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.MimeTypes
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DataSource
+import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
+import androidx.media3.exoplayer.hls.HlsMediaSource
+import androidx.media3.exoplayer.source.MediaSource
+import androidx.media3.exoplayer.source.ProgressiveMediaSource
+import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
+import androidx.media3.extractor.DefaultExtractorsFactory
+import androidx.media3.extractor.ExtractorsFactory
+
+@UnstableApi
+class DynamicMediaSourceFactory(
+ private val context: Context
+) : MediaSource.Factory {
+
+ override fun createMediaSource(mediaItem: MediaItem): MediaSource {
+ val mediaType: String? = mediaItem.mediaMetadata.extras?.getString("type", "")
+
+ val streamingCacheSize = Preferences.getStreamingCacheSize()
+ val bypassCache = mediaType == Constants.MEDIA_TYPE_RADIO
+
+ val useUpstream = when {
+ streamingCacheSize.toInt() == 0 -> true
+ streamingCacheSize > 0 && bypassCache -> true
+ streamingCacheSize > 0 && !bypassCache -> false
+ else -> true
+ }
+
+ val dataSourceFactory: DataSource.Factory = if (useUpstream) {
+ DownloadUtil.getUpstreamDataSourceFactory(context)
+ } else {
+ DownloadUtil.getCacheDataSourceFactory(context)
+ }
+
+ return when {
+ mediaItem.localConfiguration?.mimeType == MimeTypes.APPLICATION_M3U8 ||
+ mediaItem.localConfiguration?.uri?.lastPathSegment?.endsWith(".m3u8", ignoreCase = true) == true -> {
+ HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
+ }
+
+ else -> {
+ val extractorsFactory: ExtractorsFactory = DefaultExtractorsFactory()
+ ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
+ .createMediaSource(mediaItem)
+ }
+ }
+ }
+
+ override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
+ TODO("Not yet implemented")
+ }
+
+ override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
+ TODO("Not yet implemented")
+ }
+
+ override fun getSupportedTypes(): IntArray {
+ return intArrayOf(
+ C.CONTENT_TYPE_HLS,
+ C.CONTENT_TYPE_OTHER
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java
new file mode 100644
index 00000000..b8679f13
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java
@@ -0,0 +1,244 @@
+package com.cappielloantonio.tempo.util;
+
+import android.net.Uri;
+import android.os.Looper;
+import android.os.SystemClock;
+
+import androidx.documentfile.provider.DocumentFile;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+
+import com.cappielloantonio.tempo.App;
+import com.cappielloantonio.tempo.subsonic.models.Child;
+import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
+
+import java.text.Normalizer;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class ExternalAudioReader {
+
+ private static final Map cache = new ConcurrentHashMap<>();
+ private static final Object LOCK = new Object();
+ private static final ExecutorService REFRESH_EXECUTOR = Executors.newSingleThreadExecutor();
+ private static final MutableLiveData refreshEvents = new MutableLiveData<>();
+
+ private static volatile String cachedDirUri;
+ private static volatile boolean refreshInProgress = false;
+ private static volatile boolean refreshQueued = false;
+
+ private static String sanitizeFileName(String name) {
+ String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_");
+ sanitized = sanitized.replaceAll("\\s+", " ").trim();
+ return sanitized;
+ }
+
+ private static String normalizeForComparison(String name) {
+ String s = sanitizeFileName(name);
+ s = Normalizer.normalize(s, Normalizer.Form.NFKD);
+ s = s.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
+ return s.toLowerCase(Locale.ROOT);
+ }
+
+ private static void ensureCache() {
+ String uriString = Preferences.getDownloadDirectoryUri();
+ if (uriString == null) {
+ synchronized (LOCK) {
+ cache.clear();
+ cachedDirUri = null;
+ }
+ ExternalDownloadMetadataStore.clear();
+ return;
+ }
+
+ if (uriString.equals(cachedDirUri)) {
+ return;
+ }
+
+ boolean runSynchronously = false;
+ synchronized (LOCK) {
+ if (refreshInProgress) {
+ return;
+ }
+
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ scheduleRefreshLocked();
+ return;
+ }
+
+ refreshInProgress = true;
+ runSynchronously = true;
+ }
+
+ if (runSynchronously) {
+ try {
+ rebuildCache();
+ } finally {
+ onRefreshFinished();
+ }
+ }
+ }
+
+ public static void refreshCache() {
+ refreshCacheAsync();
+ }
+
+ public static void refreshCacheAsync() {
+ synchronized (LOCK) {
+ cachedDirUri = null;
+ cache.clear();
+ }
+ requestRefresh();
+ }
+
+ public static LiveData getRefreshEvents() {
+ return refreshEvents;
+ }
+
+ private static String buildKey(String artist, String title, String album) {
+ String name = artist != null && !artist.isEmpty() ? artist + " - " + title : title;
+ if (album != null && !album.isEmpty()) name += " (" + album + ")";
+ return normalizeForComparison(name);
+ }
+
+ private static Uri findUri(String artist, String title, String album) {
+ ensureCache();
+ if (cachedDirUri == null) return null;
+
+ DocumentFile file = cache.get(buildKey(artist, title, album));
+ return file != null && file.exists() ? file.getUri() : null;
+ }
+
+ public static Uri getUri(Child media) {
+ return findUri(media.getArtist(), media.getTitle(), media.getAlbum());
+ }
+
+ public static Uri getUri(PodcastEpisode episode) {
+ return findUri(episode.getArtist(), episode.getTitle(), episode.getAlbum());
+ }
+
+ public static synchronized void removeMetadata(Child media) {
+ if (media == null) {
+ return;
+ }
+
+ String key = buildKey(media.getArtist(), media.getTitle(), media.getAlbum());
+ cache.remove(key);
+ ExternalDownloadMetadataStore.remove(key);
+ }
+
+ public static boolean delete(Child media) {
+ ensureCache();
+ if (cachedDirUri == null) return false;
+
+ String key = buildKey(media.getArtist(), media.getTitle(), media.getAlbum());
+ DocumentFile file = cache.get(key);
+ boolean deleted = false;
+ if (file != null && file.exists()) {
+ deleted = file.delete();
+ }
+ if (deleted) {
+ cache.remove(key);
+ ExternalDownloadMetadataStore.remove(key);
+ }
+ return deleted;
+ }
+
+ private static void requestRefresh() {
+ synchronized (LOCK) {
+ scheduleRefreshLocked();
+ }
+ }
+
+ private static void scheduleRefreshLocked() {
+ if (refreshInProgress) {
+ refreshQueued = true;
+ return;
+ }
+
+ refreshInProgress = true;
+ REFRESH_EXECUTOR.execute(() -> {
+ try {
+ rebuildCache();
+ } finally {
+ onRefreshFinished();
+ }
+ });
+ }
+
+ private static void rebuildCache() {
+ String uriString = Preferences.getDownloadDirectoryUri();
+ if (uriString == null) {
+ synchronized (LOCK) {
+ cache.clear();
+ cachedDirUri = null;
+ }
+ ExternalDownloadMetadataStore.clear();
+ return;
+ }
+
+ DocumentFile directory = DocumentFile.fromTreeUri(App.getContext(), Uri.parse(uriString));
+ Map expectedSizes = ExternalDownloadMetadataStore.snapshot();
+ Set verifiedKeys = new HashSet<>();
+ Map newEntries = new HashMap<>();
+
+ if (directory != null && directory.canRead()) {
+ for (DocumentFile file : directory.listFiles()) {
+ if (file == null || file.isDirectory()) continue;
+ String existing = file.getName();
+ if (existing == null) continue;
+
+ String base = existing.replaceFirst("\\.[^\\.]+$", "");
+ String key = normalizeForComparison(base);
+ Long expected = expectedSizes.get(key);
+ long actualLength = file.length();
+
+ if (expected != null && expected > 0 && actualLength == expected) {
+ newEntries.put(key, file);
+ verifiedKeys.add(key);
+ } else {
+ ExternalDownloadMetadataStore.remove(key);
+ }
+ }
+ }
+
+ if (!expectedSizes.isEmpty()) {
+ if (verifiedKeys.isEmpty()) {
+ ExternalDownloadMetadataStore.clear();
+ } else {
+ for (String key : expectedSizes.keySet()) {
+ if (!verifiedKeys.contains(key)) {
+ ExternalDownloadMetadataStore.remove(key);
+ }
+ }
+ }
+ }
+
+ synchronized (LOCK) {
+ cache.clear();
+ cache.putAll(newEntries);
+ cachedDirUri = uriString;
+ }
+ }
+
+ private static void onRefreshFinished() {
+ boolean runAgain;
+ synchronized (LOCK) {
+ refreshInProgress = false;
+ runAgain = refreshQueued;
+ refreshQueued = false;
+ }
+
+ refreshEvents.postValue(SystemClock.elapsedRealtime());
+
+ if (runAgain) {
+ requestRefresh();
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java
new file mode 100644
index 00000000..efd97350
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java
@@ -0,0 +1,393 @@
+package com.cappielloantonio.tempo.util;
+
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.provider.Settings;
+import android.webkit.MimeTypeMap;
+
+import androidx.core.app.NotificationCompat;
+import androidx.documentfile.provider.DocumentFile;
+import androidx.media3.common.MediaItem;
+
+import com.cappielloantonio.tempo.model.Download;
+import com.cappielloantonio.tempo.repository.DownloadRepository;
+import com.cappielloantonio.tempo.subsonic.models.Child;
+import com.cappielloantonio.tempo.ui.activity.MainActivity;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.text.Normalizer;
+import java.util.Locale;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class ExternalAudioWriter {
+
+ private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
+ private static final int BUFFER_SIZE = 8192;
+ private static final int CONNECT_TIMEOUT_MS = 15_000;
+ private static final int READ_TIMEOUT_MS = 60_000;
+
+ private ExternalAudioWriter() {
+ }
+
+ private static String sanitizeFileName(String name) {
+ String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_");
+ sanitized = sanitized.replaceAll("\\s+", " ").trim();
+ return sanitized;
+ }
+
+ private static String normalizeForComparison(String name) {
+ String s = sanitizeFileName(name);
+ s = Normalizer.normalize(s, Normalizer.Form.NFKD);
+ s = s.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
+ return s.toLowerCase(Locale.ROOT);
+ }
+
+ private static DocumentFile findFile(DocumentFile dir, String fileName) {
+ String normalized = normalizeForComparison(fileName);
+ for (DocumentFile file : dir.listFiles()) {
+ if (file.isDirectory()) continue;
+ String existing = file.getName();
+ if (existing != null && normalizeForComparison(existing).equals(normalized)) {
+ return file;
+ }
+ }
+ return null;
+ }
+
+ public static void downloadToUserDirectory(Context context, Child child) {
+ if (context == null || child == null) {
+ return;
+ }
+ Context appContext = context.getApplicationContext();
+ MediaItem mediaItem = MappingUtil.mapDownload(child);
+ String fallbackName = child.getTitle() != null ? child.getTitle() : child.getId();
+ EXECUTOR.execute(() -> performDownload(appContext, mediaItem, fallbackName, child));
+ }
+
+ private static void performDownload(Context context, MediaItem mediaItem, String fallbackName, Child child) {
+ String uriString = Preferences.getDownloadDirectoryUri();
+ if (uriString == null) {
+ notifyUnavailable(context);
+ return;
+ }
+
+ DocumentFile directory = DocumentFile.fromTreeUri(context, Uri.parse(uriString));
+ if (directory == null || !directory.canWrite()) {
+ notifyFailure(context, "Cannot write to folder.");
+ return;
+ }
+
+ String artist = child.getArtist() != null ? child.getArtist() : "";
+ String title = child.getTitle() != null ? child.getTitle() : fallbackName;
+ String album = child.getAlbum() != null ? child.getAlbum() : "";
+ String baseName = artist.isEmpty() ? title : artist + " - " + title;
+ if (!album.isEmpty()) baseName += " (" + album + ")";
+ if (baseName.isEmpty()) {
+ baseName = fallbackName != null ? fallbackName : "download";
+ }
+ String metadataKey = normalizeForComparison(baseName);
+
+ Uri mediaUri = mediaItem != null && mediaItem.requestMetadata != null
+ ? mediaItem.requestMetadata.mediaUri
+ : null;
+ if (mediaUri == null) {
+ notifyFailure(context, "Invalid media URI.");
+ ExternalDownloadMetadataStore.remove(metadataKey);
+ return;
+ }
+
+ String scheme = mediaUri.getScheme() != null ? mediaUri.getScheme().toLowerCase(Locale.ROOT) : "";
+
+ HttpURLConnection connection = null;
+ DocumentFile sourceDocument = null;
+ File sourceFile = null;
+ long remoteLength = -1;
+ String mimeType = null;
+ DocumentFile targetFile = null;
+
+ try {
+ if (scheme.equals("http") || scheme.equals("https")) {
+ connection = (HttpURLConnection) new URL(mediaUri.toString()).openConnection();
+ connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
+ connection.setReadTimeout(READ_TIMEOUT_MS);
+ connection.setRequestProperty("Accept-Encoding", "identity");
+ connection.connect();
+
+ int responseCode = connection.getResponseCode();
+ if (responseCode >= HttpURLConnection.HTTP_BAD_REQUEST) {
+ notifyFailure(context, "Server returned " + responseCode);
+ ExternalDownloadMetadataStore.remove(metadataKey);
+ return;
+ }
+
+ mimeType = connection.getContentType();
+ remoteLength = connection.getContentLengthLong();
+ } else if (scheme.equals("content")) {
+ sourceDocument = DocumentFile.fromSingleUri(context, mediaUri);
+ mimeType = context.getContentResolver().getType(mediaUri);
+ if (sourceDocument != null) {
+ remoteLength = sourceDocument.length();
+ }
+ } else if (scheme.equals("file")) {
+ String path = mediaUri.getPath();
+ if (path != null) {
+ sourceFile = new File(path);
+ if (sourceFile.exists()) {
+ remoteLength = sourceFile.length();
+ }
+ }
+ String ext = MimeTypeMap.getFileExtensionFromUrl(mediaUri.toString());
+ if (ext != null && !ext.isEmpty()) {
+ mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
+ }
+ } else {
+ notifyFailure(context, "Unsupported media URI.");
+ ExternalDownloadMetadataStore.remove(metadataKey);
+ return;
+ }
+
+ if (mimeType == null || mimeType.isEmpty()) {
+ mimeType = "application/octet-stream";
+ }
+
+ String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
+ if ((extension == null || extension.isEmpty()) && sourceDocument != null && sourceDocument.getName() != null) {
+ String name = sourceDocument.getName();
+ int dot = name.lastIndexOf('.');
+ if (dot >= 0 && dot < name.length() - 1) {
+ extension = name.substring(dot + 1);
+ }
+ }
+ if ((extension == null || extension.isEmpty()) && sourceFile != null) {
+ String name = sourceFile.getName();
+ int dot = name.lastIndexOf('.');
+ if (dot >= 0 && dot < name.length() - 1) {
+ extension = name.substring(dot + 1);
+ }
+ }
+ if (extension == null || extension.isEmpty()) {
+ String suffix = child.getSuffix();
+ if (suffix != null && !suffix.isEmpty()) {
+ extension = suffix;
+ } else {
+ extension = "bin";
+ }
+ }
+
+ String sanitized = sanitizeFileName(baseName);
+ if (sanitized.isEmpty()) sanitized = sanitizeFileName(fallbackName);
+ if (sanitized.isEmpty()) sanitized = "download";
+ String fileName = sanitized + "." + extension;
+
+ DocumentFile existingFile = findFile(directory, fileName);
+ Long recordedSize = ExternalDownloadMetadataStore.getSize(metadataKey);
+ if (existingFile != null && existingFile.exists()) {
+ long localLength = existingFile.length();
+ boolean matches = false;
+ if (remoteLength > 0 && localLength == remoteLength) {
+ matches = true;
+ } else if (remoteLength <= 0 && recordedSize != null && localLength == recordedSize) {
+ matches = true;
+ }
+ if (matches) {
+ ExternalDownloadMetadataStore.recordSize(metadataKey, localLength);
+ recordDownload(child, existingFile.getUri());
+ ExternalAudioReader.refreshCacheAsync();
+ notifyExists(context, fileName);
+ return;
+ } else {
+ existingFile.delete();
+ ExternalDownloadMetadataStore.remove(metadataKey);
+ }
+ }
+
+ targetFile = directory.createFile(mimeType, fileName);
+ if (targetFile == null) {
+ notifyFailure(context, "Failed to create file.");
+ return;
+ }
+
+ Uri targetUri = targetFile.getUri();
+ try (InputStream in = openInputStream(context, mediaUri, scheme, connection, sourceFile);
+ OutputStream out = context.getContentResolver().openOutputStream(targetUri)) {
+ if (out == null) {
+ notifyFailure(context, "Cannot open output stream.");
+ targetFile.delete();
+ return;
+ }
+
+ byte[] buffer = new byte[BUFFER_SIZE];
+ int len;
+ long total = 0;
+ while ((len = in.read(buffer)) != -1) {
+ out.write(buffer, 0, len);
+ total += len;
+ }
+ out.flush();
+
+ if (total <= 0) {
+ targetFile.delete();
+ ExternalDownloadMetadataStore.remove(metadataKey);
+ notifyFailure(context, "Empty download.");
+ return;
+ }
+
+ if (remoteLength > 0 && total != remoteLength) {
+ targetFile.delete();
+ ExternalDownloadMetadataStore.remove(metadataKey);
+ notifyFailure(context, "Incomplete download.");
+ return;
+ }
+
+ ExternalDownloadMetadataStore.recordSize(metadataKey, total);
+ recordDownload(child, targetUri);
+ notifySuccess(context, fileName, child, targetUri);
+ ExternalAudioReader.refreshCacheAsync();
+ }
+ } catch (Exception e) {
+ if (targetFile != null) {
+ targetFile.delete();
+ }
+ ExternalDownloadMetadataStore.remove(metadataKey);
+ notifyFailure(context, e.getMessage() != null ? e.getMessage() : "Download failed");
+ } finally {
+ if (connection != null) {
+ connection.disconnect();
+ }
+ }
+ }
+
+ private static void notifyUnavailable(Context context) {
+ NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ Intent settingsIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts("package", context.getPackageName(), null));
+ PendingIntent openSettings = PendingIntent.getActivity(context, 0, settingsIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
+ .setContentTitle("No download folder set")
+ .setContentText("Tap to set one in settings")
+ .setSmallIcon(android.R.drawable.stat_notify_error)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setSilent(true)
+ .setContentIntent(openSettings)
+ .setAutoCancel(true);
+
+ manager.notify(1011, builder.build());
+ }
+
+ private static void notifyFailure(Context context, String message) {
+ NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
+ .setContentTitle("Download failed")
+ .setContentText(message)
+ .setSmallIcon(android.R.drawable.stat_notify_error)
+ .setAutoCancel(true);
+ manager.notify((int) System.currentTimeMillis(), builder.build());
+ }
+
+ private static void notifySuccess(Context context, String name, Child child, Uri fileUri) {
+ NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
+ .setContentTitle("Download complete")
+ .setContentText(name)
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
+ .setAutoCancel(true);
+
+ PendingIntent playIntent = buildPlayIntent(context, child, fileUri);
+ if (playIntent != null) {
+ builder.setContentIntent(playIntent);
+ }
+
+ manager.notify((int) System.currentTimeMillis(), builder.build());
+ }
+
+ private static void recordDownload(Child child, Uri fileUri) {
+ if (child == null) {
+ return;
+ }
+
+ Download download = new Download(child);
+ download.setDownloadState(1);
+ if (fileUri != null) {
+ download.setDownloadUri(fileUri.toString());
+ }
+
+ new DownloadRepository().insert(download);
+ }
+
+ private static void notifyExists(Context context, String name) {
+ NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
+ .setContentTitle("Already downloaded")
+ .setContentText(name)
+ .setSmallIcon(android.R.drawable.stat_sys_warning)
+ .setAutoCancel(true);
+ manager.notify((int) System.currentTimeMillis(), builder.build());
+ }
+
+ private static PendingIntent buildPlayIntent(Context context, Child child, Uri fileUri) {
+ if (fileUri == null) return null;
+ Intent intent = new Intent(context, MainActivity.class)
+ .setAction(Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD)
+ .putExtra(Constants.EXTRA_DOWNLOAD_URI, fileUri.toString())
+ .putExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID, child.getId())
+ .putExtra(Constants.EXTRA_DOWNLOAD_TITLE, child.getTitle())
+ .putExtra(Constants.EXTRA_DOWNLOAD_ARTIST, child.getArtist())
+ .putExtra(Constants.EXTRA_DOWNLOAD_ALBUM, child.getAlbum())
+ .putExtra(Constants.EXTRA_DOWNLOAD_DURATION, child.getDuration() != null ? child.getDuration() : 0)
+ .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+
+ int requestCode;
+ if (child.getId() != null) {
+ requestCode = Math.abs(child.getId().hashCode());
+ } else {
+ requestCode = Math.abs(fileUri.toString().hashCode());
+ }
+
+ return PendingIntent.getActivity(
+ context,
+ requestCode,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
+ );
+ }
+
+ private static InputStream openInputStream(Context context,
+ Uri mediaUri,
+ String scheme,
+ HttpURLConnection connection,
+ File sourceFile) throws IOException {
+ switch (scheme) {
+ case "http":
+ case "https":
+ if (connection == null) {
+ throw new IOException("Connection not initialized");
+ }
+ return connection.getInputStream();
+ case "content":
+ InputStream contentStream = context.getContentResolver().openInputStream(mediaUri);
+ if (contentStream == null) {
+ throw new IOException("Cannot open content stream");
+ }
+ return contentStream;
+ case "file":
+ if (sourceFile == null || !sourceFile.exists()) {
+ throw new IOException("Missing source file");
+ }
+ return new FileInputStream(sourceFile);
+ default:
+ throw new IOException("Unsupported scheme " + scheme);
+ }
+ }
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalDownloadMetadataStore.java b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalDownloadMetadataStore.java
new file mode 100644
index 00000000..4bd4089d
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalDownloadMetadataStore.java
@@ -0,0 +1,123 @@
+package com.cappielloantonio.tempo.util;
+
+import android.content.SharedPreferences;
+
+import androidx.annotation.Nullable;
+
+import com.cappielloantonio.tempo.App;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+
+public final class ExternalDownloadMetadataStore {
+
+ private static final String PREF_KEY = "external_download_metadata";
+
+ private ExternalDownloadMetadataStore() {
+ }
+
+ private static SharedPreferences preferences() {
+ return App.getInstance().getPreferences();
+ }
+
+ private static JSONObject readAll() {
+ String raw = preferences().getString(PREF_KEY, "{}");
+ try {
+ return new JSONObject(raw);
+ } catch (JSONException e) {
+ return new JSONObject();
+ }
+ }
+
+ private static void writeAll(JSONObject object) {
+ preferences().edit().putString(PREF_KEY, object.toString()).apply();
+ }
+
+ public static synchronized void clear() {
+ writeAll(new JSONObject());
+ }
+
+ public static synchronized void recordSize(String key, long size) {
+ if (key == null || size <= 0) {
+ return;
+ }
+ JSONObject object = readAll();
+ try {
+ object.put(key, size);
+ } catch (JSONException ignored) {
+ }
+ writeAll(object);
+ }
+
+ public static synchronized void remove(String key) {
+ if (key == null) {
+ return;
+ }
+ JSONObject object = readAll();
+ object.remove(key);
+ writeAll(object);
+ }
+
+ @Nullable
+ public static synchronized Long getSize(String key) {
+ if (key == null) {
+ return null;
+ }
+ JSONObject object = readAll();
+ if (!object.has(key)) {
+ return null;
+ }
+ long size = object.optLong(key, -1L);
+ return size > 0 ? size : null;
+ }
+
+ public static synchronized Map snapshot() {
+ JSONObject object = readAll();
+ if (object.length() == 0) {
+ return Collections.emptyMap();
+ }
+ Map sizes = new HashMap<>();
+ Iterator keys = object.keys();
+ while (keys.hasNext()) {
+ String key = keys.next();
+ long size = object.optLong(key, -1L);
+ if (size > 0) {
+ sizes.put(key, size);
+ }
+ }
+ return sizes;
+ }
+
+ public static synchronized void retainOnly(Set keysToKeep) {
+ if (keysToKeep == null || keysToKeep.isEmpty()) {
+ clear();
+ return;
+ }
+ JSONObject object = readAll();
+ if (object.length() == 0) {
+ return;
+ }
+ Set keys = new HashSet<>();
+ Iterator iterator = object.keys();
+ while (iterator.hasNext()) {
+ keys.add(iterator.next());
+ }
+ boolean changed = false;
+ for (String key : keys) {
+ if (!keysToKeep.contains(key)) {
+ object.remove(key);
+ changed = true;
+ }
+ }
+ if (changed) {
+ writeAll(object);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java
index f8f15f07..558aa218 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java
@@ -4,10 +4,12 @@ import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.OptIn;
+import androidx.lifecycle.LifecycleOwner;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
+import androidx.media3.common.HeartRating;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
@@ -16,6 +18,7 @@ import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
+import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.List;
@@ -71,6 +74,12 @@ public class MappingUtil {
bundle.putInt("originalWidth", media.getOriginalWidth() != null ? media.getOriginalWidth() : 0);
bundle.putInt("originalHeight", media.getOriginalHeight() != null ? media.getOriginalHeight() : 0);
bundle.putString("uri", uri.toString());
+ bundle.putString("assetLinkSong", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, media.getId()));
+ bundle.putString("assetLinkAlbum", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, media.getAlbumId()));
+ bundle.putString("assetLinkArtist", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, media.getArtistId()));
+ bundle.putString("assetLinkGenre", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_GENRE, media.getGenre()));
+ Integer year = media.getYear();
+ bundle.putString("assetLinkYear", year != null && year != 0 ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(year)) : null);
return new MediaItem.Builder()
.setMediaId(media.getId())
@@ -83,6 +92,13 @@ public class MappingUtil {
.setAlbumTitle(media.getAlbum())
.setArtist(media.getArtist())
.setArtworkUri(artworkUri)
+ .setUserRating(new HeartRating(media.getStarred() != null))
+ .setSupportedCommands(
+ ImmutableList.of(
+ Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON,
+ Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
+ )
+ )
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
@@ -110,6 +126,11 @@ public class MappingUtil {
}
public static MediaItem mapDownload(Child media) {
+
+ Bundle bundle = new Bundle();
+ bundle.putInt("samplingRate", media.getSamplingRate() != null ? media.getSamplingRate() : 0);
+ bundle.putInt("bitDepth", media.getBitDepth() != null ? media.getBitDepth() : 0);
+
return new MediaItem.Builder()
.setMediaId(media.getId())
.setMediaMetadata(
@@ -120,12 +141,14 @@ public class MappingUtil {
.setReleaseYear(media.getYear() != null ? media.getYear() : 0)
.setAlbumTitle(media.getAlbum())
.setArtist(media.getArtist())
+ .setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
.build()
)
.setRequestMetadata(
new MediaItem.RequestMetadata.Builder()
+ .setExtras(bundle)
.setMediaUri(Preferences.preferTranscodedDownload() ? MusicUtil.getTranscodedDownloadUri(media.getId()) : MusicUtil.getDownloadUri(media.getId()))
.build()
)
@@ -217,12 +240,20 @@ public class MappingUtil {
}
private static Uri getUri(Child media) {
+ if (Preferences.getDownloadDirectoryUri() != null) {
+ Uri local = ExternalAudioReader.getUri(media);
+ return local != null ? local : MusicUtil.getStreamUri(media.getId());
+ }
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(media.getId())
? getDownloadUri(media.getId())
: MusicUtil.getStreamUri(media.getId());
}
private static Uri getUri(PodcastEpisode podcastEpisode) {
+ if (Preferences.getDownloadDirectoryUri() != null) {
+ Uri local = ExternalAudioReader.getUri(podcastEpisode);
+ return local != null ? local : MusicUtil.getStreamUri(podcastEpisode.getStreamId());
+ }
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(podcastEpisode.getStreamId())
? getDownloadUri(podcastEpisode.getStreamId())
: MusicUtil.getStreamUri(podcastEpisode.getStreamId());
@@ -232,4 +263,11 @@ public class MappingUtil {
Download download = new DownloadRepository().getDownload(id);
return download != null && !download.getDownloadUri().isEmpty() ? Uri.parse(download.getDownloadUri()) : MusicUtil.getDownloadUri(id);
}
+
+ public static void observeExternalAudioRefresh(LifecycleOwner owner, Runnable onRefresh) {
+ if (owner == null || onRefresh == null) {
+ return;
+ }
+ ExternalAudioReader.getRefreshEvents().observe(owner, event -> onRefresh.run());
+ }
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt
index 8c77ab13..cd7459e8 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt
@@ -37,6 +37,7 @@ object Preferences {
private const val WIFI_ONLY = "wifi_only"
private const val DATA_SAVING_MODE = "data_saving_mode"
private const val SERVER_UNREACHABLE = "server_unreachable"
+ private const val SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE = "sync_starred_artists_for_offline_use"
private const val SYNC_STARRED_ALBUMS_FOR_OFFLINE_USE = "sync_starred_albums_for_offline_use"
private const val SYNC_STARRED_TRACKS_FOR_OFFLINE_USE = "sync_starred_tracks_for_offline_use"
private const val QUEUE_SYNCING = "queue_syncing"
@@ -45,11 +46,13 @@ object Preferences {
private const val ROUNDED_CORNER_SIZE = "rounded_corner_size"
private const val PODCAST_SECTION_VISIBILITY = "podcast_section_visibility"
private const val RADIO_SECTION_VISIBILITY = "radio_section_visibility"
+ private const val AUTO_DOWNLOAD_LYRICS = "auto_download_lyrics"
private const val MUSIC_DIRECTORY_SECTION_VISIBILITY = "music_directory_section_visibility"
private const val REPLAY_GAIN_MODE = "replay_gain_mode"
private const val AUDIO_TRANSCODE_PRIORITY = "audio_transcode_priority"
private const val STREAMING_CACHE_STORAGE = "streaming_cache_storage"
private const val DOWNLOAD_STORAGE = "download_storage"
+ private const val DOWNLOAD_DIRECTORY_URI = "download_directory_uri"
private const val DEFAULT_DOWNLOAD_VIEW_TYPE = "default_download_view_type"
private const val AUDIO_TRANSCODE_DOWNLOAD = "audio_transcode_download"
private const val AUDIO_TRANSCODE_DOWNLOAD_PRIORITY = "audio_transcode_download_priority"
@@ -69,7 +72,12 @@ object Preferences {
private const val NEXT_UPDATE_CHECK = "next_update_check"
private const val CONTINUOUS_PLAY = "continuous_play"
private const val LAST_INSTANT_MIX = "last_instant_mix"
-
+ private const val ALLOW_PLAYLIST_DUPLICATES = "allow_playlist_duplicates"
+ private const val EQUALIZER_ENABLED = "equalizer_enabled"
+ private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels"
+ private const val MINI_SHUFFLE_BUTTON_VISIBILITY = "mini_shuffle_button_visibility"
+ private const val ALBUM_SORT_ORDER = "album_sort_order"
+ private const val DEFAULT_ALBUM_SORT_ORDER = Constants.ALBUM_ORDER_BY_NAME
@JvmStatic
fun getServer(): String? {
@@ -161,6 +169,24 @@ object Preferences {
App.getInstance().preferences.edit().putString(OPEN_SUBSONIC_EXTENSIONS, Gson().toJson(extension)).apply()
}
+ @JvmStatic
+ fun isAutoDownloadLyricsEnabled(): Boolean {
+ val preferences = App.getInstance().preferences
+
+ if (preferences.contains(AUTO_DOWNLOAD_LYRICS)) {
+ return preferences.getBoolean(AUTO_DOWNLOAD_LYRICS, false)
+ }
+
+ return false
+ }
+
+ @JvmStatic
+ fun setAutoDownloadLyricsEnabled(isEnabled: Boolean) {
+ App.getInstance().preferences.edit()
+ .putBoolean(AUTO_DOWNLOAD_LYRICS, isEnabled)
+ .apply()
+ }
+
@JvmStatic
fun getLocalAddress(): String? {
return App.getInstance().preferences.getString(LOCAL_ADDRESS, null)
@@ -302,6 +328,18 @@ object Preferences {
.apply()
}
+ @JvmStatic
+ fun isStarredArtistsSyncEnabled(): Boolean {
+ return App.getInstance().preferences.getBoolean(SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE, false)
+ }
+
+ @JvmStatic
+ fun setStarredArtistsSyncEnabled(isStarredSyncEnabled: Boolean) {
+ App.getInstance().preferences.edit().putBoolean(
+ SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE, isStarredSyncEnabled
+ ).apply()
+ }
+
@JvmStatic
fun isStarredAlbumsSyncEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(SYNC_STARRED_ALBUMS_FOR_OFFLINE_USE, false)
@@ -326,6 +364,16 @@ object Preferences {
).apply()
}
+ @JvmStatic
+ fun showShuffleInsteadOfHeart(): Boolean {
+ return App.getInstance().preferences.getBoolean(MINI_SHUFFLE_BUTTON_VISIBILITY, false)
+ }
+
+ @JvmStatic
+ fun setShuffleInsteadOfHeart(enabled: Boolean) {
+ App.getInstance().preferences.edit().putBoolean(MINI_SHUFFLE_BUTTON_VISIBILITY, enabled).apply()
+ }
+
@JvmStatic
fun showServerUnreachableDialog(): Boolean {
return App.getInstance().preferences.getLong(
@@ -419,6 +467,20 @@ object Preferences {
).apply()
}
+ @JvmStatic
+ fun getDownloadDirectoryUri(): String? {
+ return App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null)
+ }
+
+ @JvmStatic
+ fun setDownloadDirectoryUri(uri: String?) {
+ val current = App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null)
+ if (current != uri) {
+ ExternalDownloadMetadataStore.clear()
+ }
+ App.getInstance().preferences.edit().putString(DOWNLOAD_DIRECTORY_URI, uri).apply()
+ }
+
@JvmStatic
fun getDefaultDownloadViewType(): String {
return App.getInstance().preferences.getString(
@@ -538,4 +600,54 @@ object Preferences {
LAST_INSTANT_MIX, 0
) + 5000 < System.currentTimeMillis()
}
+
+ @JvmStatic
+ fun setAllowPlaylistDuplicates(allowDuplicates: Boolean) {
+ return App.getInstance().preferences.edit().putString(
+ ALLOW_PLAYLIST_DUPLICATES,
+ allowDuplicates.toString()
+ ).apply()
+ }
+
+ @JvmStatic
+ fun allowPlaylistDuplicates(): Boolean {
+ return App.getInstance().preferences.getBoolean(ALLOW_PLAYLIST_DUPLICATES, false)
+ }
+
+ @JvmStatic
+ fun setEqualizerEnabled(enabled: Boolean) {
+ App.getInstance().preferences.edit().putBoolean(EQUALIZER_ENABLED, enabled).apply()
+ }
+
+ @JvmStatic
+ fun isEqualizerEnabled(): Boolean {
+ return App.getInstance().preferences.getBoolean(EQUALIZER_ENABLED, false)
+ }
+
+ @JvmStatic
+ fun setEqualizerBandLevels(bandLevels: ShortArray) {
+ val asString = bandLevels.joinToString(",")
+ App.getInstance().preferences.edit().putString(EQUALIZER_BAND_LEVELS, asString).apply()
+ }
+
+ @JvmStatic
+ fun getEqualizerBandLevels(bandCount: Short): ShortArray {
+ val str = App.getInstance().preferences.getString(EQUALIZER_BAND_LEVELS, null)
+ if (str.isNullOrBlank()) {
+ return ShortArray(bandCount.toInt())
+ }
+ val parts = str.split(",")
+ if (parts.size < bandCount) return ShortArray(bandCount.toInt())
+ return ShortArray(bandCount.toInt()) { i -> parts[i].toShortOrNull() ?: 0 }
+ }
+
+ @JvmStatic
+ fun getAlbumSortOrder(): String {
+ return App.getInstance().preferences.getString(ALBUM_SORT_ORDER, DEFAULT_ALBUM_SORT_ORDER) ?: DEFAULT_ALBUM_SORT_ORDER
+ }
+
+ @JvmStatic
+ fun setAlbumSortOrder(sortOrder: String) {
+ App.getInstance().preferences.edit().putString(ALBUM_SORT_ORDER, sortOrder).apply()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistBottomSheetViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistBottomSheetViewModel.java
index 08ae3681..2c008d80 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistBottomSheetViewModel.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistBottomSheetViewModel.java
@@ -1,17 +1,25 @@
package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
-
+import android.content.Context;
+import android.util.Log;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
+import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.FavoriteRepository;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
+import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.util.NetworkUtil;
+import com.cappielloantonio.tempo.util.DownloadUtil;
+import com.cappielloantonio.tempo.util.MappingUtil;
+import com.cappielloantonio.tempo.util.Preferences;
import java.util.Date;
+import java.util.stream.Collectors;
+import java.util.List;
public class ArtistBottomSheetViewModel extends AndroidViewModel {
private final ArtistRepository artistRepository;
@@ -34,7 +42,7 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
this.artist = artist;
}
- public void setFavorite() {
+ public void setFavorite(Context context) {
if (artist.getStarred() != null) {
if (NetworkUtil.isOffline()) {
removeFavoriteOffline();
@@ -43,9 +51,9 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
}
} else {
if (NetworkUtil.isOffline()) {
- setFavoriteOffline();
+ setFavoriteOffline(context);
} else {
- setFavoriteOnline();
+ setFavoriteOnline(context);
}
}
}
@@ -59,7 +67,6 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
favoriteRepository.unstar(null, null, artist.getId(), new StarCallback() {
@Override
public void onError() {
- // artist.setStarred(new Date());
favoriteRepository.starLater(null, null, artist.getId(), false);
}
});
@@ -67,20 +74,45 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
artist.setStarred(null);
}
- private void setFavoriteOffline() {
+ private void setFavoriteOffline(Context context) {
favoriteRepository.starLater(null, null, artist.getId(), true);
artist.setStarred(new Date());
}
- private void setFavoriteOnline() {
+ private void setFavoriteOnline(Context context) {
favoriteRepository.star(null, null, artist.getId(), new StarCallback() {
@Override
public void onError() {
- // artist.setStarred(null);
favoriteRepository.starLater(null, null, artist.getId(), true);
}
});
artist.setStarred(new Date());
+
+ Log.d("ArtistSync", "Checking preference: " + Preferences.isStarredArtistsSyncEnabled());
+
+ if (Preferences.isStarredArtistsSyncEnabled()) {
+ Log.d("ArtistSync", "Starting artist sync for: " + artist.getName());
+
+ artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() {
+ @Override
+ public void onSongsCollected(List songs) {
+ Log.d("ArtistSync", "Callback triggered with songs: " + (songs != null ? songs.size() : 0));
+ if (songs != null && !songs.isEmpty()) {
+ Log.d("ArtistSync", "Starting download of " + songs.size() + " songs");
+ DownloadUtil.getDownloadTracker(context).download(
+ MappingUtil.mapDownloads(songs),
+ songs.stream().map(Download::new).collect(Collectors.toList())
+ );
+ Log.d("ArtistSync", "Download started successfully");
+ } else {
+ Log.d("ArtistSync", "No songs to download");
+ }
+ }
+ });
+ } else {
+ Log.d("ArtistSync", "Artist sync preference is disabled");
+ }
}
+ ///
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/DownloadViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/DownloadViewModel.java
index 3059eb09..6f69cee1 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/DownloadViewModel.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/DownloadViewModel.java
@@ -1,6 +1,7 @@
package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
+import android.net.Uri;
import android.util.Log;
import androidx.annotation.NonNull;
@@ -8,10 +9,13 @@ import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
+import androidx.documentfile.provider.DocumentFile;
+import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.model.DownloadStack;
import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.Preferences;
import java.util.ArrayList;
@@ -25,6 +29,7 @@ public class DownloadViewModel extends AndroidViewModel {
private final MutableLiveData> downloadedTrackSample = new MutableLiveData<>(null);
private final MutableLiveData> viewStack = new MutableLiveData<>(null);
+ private final MutableLiveData refreshResult = new MutableLiveData<>();
public DownloadViewModel(@NonNull Application application) {
super(application);
@@ -43,6 +48,10 @@ public class DownloadViewModel extends AndroidViewModel {
return viewStack;
}
+ public LiveData getRefreshResult() {
+ return refreshResult;
+ }
+
public void initViewStack(DownloadStack level) {
ArrayList stack = new ArrayList<>();
stack.add(level);
@@ -60,4 +69,59 @@ public class DownloadViewModel extends AndroidViewModel {
stack.remove(stack.size() - 1);
viewStack.setValue(stack);
}
+
+ public void refreshExternalDownloads() {
+ new Thread(() -> {
+ String directoryUri = Preferences.getDownloadDirectoryUri();
+ if (directoryUri == null) {
+ refreshResult.postValue(-1);
+ return;
+ }
+
+ List downloads = downloadRepository.getAllDownloads();
+ if (downloads == null || downloads.isEmpty()) {
+ refreshResult.postValue(0);
+ return;
+ }
+
+ ArrayList toRemove = new ArrayList<>();
+
+ for (Download download : downloads) {
+ String uriString = download.getDownloadUri();
+ if (uriString == null || uriString.isEmpty()) {
+ continue;
+ }
+
+ Uri uri = Uri.parse(uriString);
+ if (uri.getScheme() == null || !uri.getScheme().equalsIgnoreCase("content")) {
+ continue;
+ }
+
+ DocumentFile file;
+ try {
+ file = DocumentFile.fromSingleUri(getApplication(), uri);
+ } catch (SecurityException exception) {
+ file = null;
+ }
+
+ if (file == null || !file.exists()) {
+ toRemove.add(download);
+ }
+ }
+
+ if (!toRemove.isEmpty()) {
+ ArrayList ids = new ArrayList<>();
+ for (Download download : toRemove) {
+ ids.add(download.getId());
+ ExternalAudioReader.removeMetadata(download);
+ }
+
+ downloadRepository.delete(ids);
+ ExternalAudioReader.refreshCache();
+ refreshResult.postValue(ids.size());
+ } else {
+ refreshResult.postValue(0);
+ }
+ }).start();
+ }
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java
index 6477178c..2089ce20 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java
@@ -48,6 +48,7 @@ public class HomeViewModel extends AndroidViewModel {
private final SharingRepository sharingRepository;
private final StarredAlbumsSyncViewModel albumsSyncViewModel;
+ private final StarredArtistsSyncViewModel artistSyncViewModel;
private final MutableLiveData> dicoverSongSample = new MutableLiveData<>(null);
private final MutableLiveData> newReleasedAlbum = new MutableLiveData<>(null);
@@ -85,6 +86,7 @@ public class HomeViewModel extends AndroidViewModel {
sharingRepository = new SharingRepository();
albumsSyncViewModel = new StarredAlbumsSyncViewModel(application);
+ artistSyncViewModel = new StarredArtistsSyncViewModel(application);
setOfflineFavorite();
}
@@ -174,6 +176,10 @@ public class HomeViewModel extends AndroidViewModel {
return albumsSyncViewModel.getAllStarredAlbumSongs();
}
+ public LiveData> getAllStarredArtistSongs() {
+ return artistSyncViewModel.getAllStarredArtistSongs();
+ }
+
public LiveData> getStarredArtists(LifecycleOwner owner) {
if (starredArtists.getValue() == null) {
artistRepository.getStarredArtists(true, 20).observe(owner, starredArtists::postValue);
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaybackViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaybackViewModel.java
new file mode 100644
index 00000000..b1808d9f
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaybackViewModel.java
@@ -0,0 +1,35 @@
+package com.cappielloantonio.tempo.viewmodel;
+
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.ViewModel;
+
+import java.util.Objects;
+
+public class PlaybackViewModel extends ViewModel {
+
+ private final MutableLiveData currentSongId = new MutableLiveData<>(null);
+ private final MutableLiveData isPlaying = new MutableLiveData<>(false);
+
+ public LiveData getCurrentSongId() {
+ return currentSongId;
+ }
+
+ public LiveData getIsPlaying() {
+ return isPlaying;
+ }
+
+ public void update(String songId, boolean playing) {
+ if (!Objects.equals(currentSongId.getValue(), songId)) {
+ currentSongId.postValue(songId);
+ }
+ if (!Objects.equals(isPlaying.getValue(), playing)) {
+ isPlaying.postValue(playing);
+ }
+ }
+
+ public void clear() {
+ currentSongId.postValue(null);
+ isPlaying.postValue(false);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java
index bf90fa65..2a100fbf 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java
@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
import android.content.Context;
+import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
@@ -9,14 +10,17 @@ import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.Observer;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.model.Download;
+import com.cappielloantonio.tempo.model.LyricsCache;
import com.cappielloantonio.tempo.model.Queue;
import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.FavoriteRepository;
+import com.cappielloantonio.tempo.repository.LyricsRepository;
import com.cappielloantonio.tempo.repository.OpenRepository;
import com.cappielloantonio.tempo.repository.QueueRepository;
import com.cappielloantonio.tempo.repository.SongRepository;
@@ -31,6 +35,7 @@ import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.NetworkUtil;
import com.cappielloantonio.tempo.util.OpenSubsonicExtensionsUtil;
import com.cappielloantonio.tempo.util.Preferences;
+import com.google.gson.Gson;
import java.util.Collections;
import java.util.Date;
@@ -47,14 +52,20 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
private final QueueRepository queueRepository;
private final FavoriteRepository favoriteRepository;
private final OpenRepository openRepository;
+ private final LyricsRepository lyricsRepository;
private final MutableLiveData lyricsLiveData = new MutableLiveData<>(null);
private final MutableLiveData lyricsListLiveData = new MutableLiveData<>(null);
+ private final MutableLiveData lyricsCachedLiveData = new MutableLiveData<>(false);
private final MutableLiveData descriptionLiveData = new MutableLiveData<>(null);
private final MutableLiveData liveMedia = new MutableLiveData<>(null);
private final MutableLiveData liveAlbum = new MutableLiveData<>(null);
private final MutableLiveData liveArtist = new MutableLiveData<>(null);
private final MutableLiveData> instantMix = new MutableLiveData<>(null);
+ private final Gson gson = new Gson();
private boolean lyricsSyncState = true;
+ private LiveData cachedLyricsSource;
+ private String currentSongId;
+ private final Observer cachedLyricsObserver = this::onCachedLyricsChanged;
public PlayerBottomSheetViewModel(@NonNull Application application) {
@@ -66,6 +77,7 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
queueRepository = new QueueRepository();
favoriteRepository = new FavoriteRepository();
openRepository = new OpenRepository();
+ lyricsRepository = new LyricsRepository();
}
public LiveData> getQueueSong() {
@@ -122,7 +134,7 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
media.setStarred(new Date());
- if (Preferences.isStarredSyncEnabled()) {
+ if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownload(media),
new Download(media)
@@ -139,12 +151,49 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
}
public void refreshMediaInfo(LifecycleOwner owner, Child media) {
+ lyricsLiveData.postValue(null);
+ lyricsListLiveData.postValue(null);
+ lyricsCachedLiveData.postValue(false);
+
+ clearCachedLyricsObserver();
+
+ String songId = media != null ? media.getId() : currentSongId;
+
+ if (TextUtils.isEmpty(songId) || owner == null) {
+ return;
+ }
+
+ currentSongId = songId;
+
+ observeCachedLyrics(owner, songId);
+
+ LyricsCache cachedLyrics = lyricsRepository.getLyrics(songId);
+ if (cachedLyrics != null) {
+ onCachedLyricsChanged(cachedLyrics);
+ }
+
+ if (NetworkUtil.isOffline() || media == null) {
+ return;
+ }
+
if (OpenSubsonicExtensionsUtil.isSongLyricsExtensionAvailable()) {
- openRepository.getLyricsBySongId(media.getId()).observe(owner, lyricsListLiveData::postValue);
- lyricsLiveData.postValue(null);
+ openRepository.getLyricsBySongId(media.getId()).observe(owner, lyricsList -> {
+ lyricsListLiveData.postValue(lyricsList);
+ lyricsLiveData.postValue(null);
+
+ if (shouldAutoDownloadLyrics() && hasStructuredLyrics(lyricsList)) {
+ saveLyricsToCache(media, null, lyricsList);
+ }
+ });
} else {
- songRepository.getSongLyrics(media).observe(owner, lyricsLiveData::postValue);
- lyricsListLiveData.postValue(null);
+ songRepository.getSongLyrics(media).observe(owner, lyrics -> {
+ lyricsLiveData.postValue(lyrics);
+ lyricsListLiveData.postValue(null);
+
+ if (shouldAutoDownloadLyrics() && !TextUtils.isEmpty(lyrics)) {
+ saveLyricsToCache(media, lyrics, null);
+ }
+ });
}
}
@@ -153,6 +202,17 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
}
public void setLiveMedia(LifecycleOwner owner, String mediaType, String mediaId) {
+ currentSongId = mediaId;
+
+ if (!TextUtils.isEmpty(mediaId)) {
+ refreshMediaInfo(owner, null);
+ } else {
+ clearCachedLyricsObserver();
+ lyricsLiveData.postValue(null);
+ lyricsListLiveData.postValue(null);
+ lyricsCachedLiveData.postValue(false);
+ }
+
if (mediaType != null) {
switch (mediaType) {
case Constants.MEDIA_TYPE_MUSIC:
@@ -162,7 +222,12 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
case Constants.MEDIA_TYPE_PODCAST:
liveMedia.postValue(null);
break;
+ default:
+ liveMedia.postValue(null);
+ break;
}
+ } else {
+ liveMedia.postValue(null);
}
}
@@ -233,6 +298,105 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
return false;
}
+ private void observeCachedLyrics(LifecycleOwner owner, String songId) {
+ if (TextUtils.isEmpty(songId)) {
+ return;
+ }
+
+ cachedLyricsSource = lyricsRepository.observeLyrics(songId);
+ cachedLyricsSource.observe(owner, cachedLyricsObserver);
+ }
+
+ private void clearCachedLyricsObserver() {
+ if (cachedLyricsSource != null) {
+ cachedLyricsSource.removeObserver(cachedLyricsObserver);
+ cachedLyricsSource = null;
+ }
+ }
+
+ private void onCachedLyricsChanged(LyricsCache lyricsCache) {
+ if (lyricsCache == null) {
+ lyricsCachedLiveData.postValue(false);
+ return;
+ }
+
+ lyricsCachedLiveData.postValue(true);
+
+ if (!TextUtils.isEmpty(lyricsCache.getStructuredLyrics())) {
+ try {
+ LyricsList cachedList = gson.fromJson(lyricsCache.getStructuredLyrics(), LyricsList.class);
+ lyricsListLiveData.postValue(cachedList);
+ lyricsLiveData.postValue(null);
+ } catch (Exception exception) {
+ lyricsListLiveData.postValue(null);
+ lyricsLiveData.postValue(lyricsCache.getLyrics());
+ }
+ } else {
+ lyricsListLiveData.postValue(null);
+ lyricsLiveData.postValue(lyricsCache.getLyrics());
+ }
+ }
+
+ private void saveLyricsToCache(Child media, String lyrics, LyricsList lyricsList) {
+ if (media == null) {
+ return;
+ }
+
+ if ((lyricsList == null || !hasStructuredLyrics(lyricsList)) && TextUtils.isEmpty(lyrics)) {
+ return;
+ }
+
+ LyricsCache lyricsCache = new LyricsCache(media.getId());
+ lyricsCache.setArtist(media.getArtist());
+ lyricsCache.setTitle(media.getTitle());
+ lyricsCache.setUpdatedAt(System.currentTimeMillis());
+
+ if (lyricsList != null && hasStructuredLyrics(lyricsList)) {
+ lyricsCache.setStructuredLyrics(gson.toJson(lyricsList));
+ lyricsCache.setLyrics(null);
+ } else {
+ lyricsCache.setLyrics(lyrics);
+ lyricsCache.setStructuredLyrics(null);
+ }
+
+ lyricsRepository.insert(lyricsCache);
+ lyricsCachedLiveData.postValue(true);
+ }
+
+ private boolean hasStructuredLyrics(LyricsList lyricsList) {
+ return lyricsList != null
+ && lyricsList.getStructuredLyrics() != null
+ && !lyricsList.getStructuredLyrics().isEmpty()
+ && lyricsList.getStructuredLyrics().get(0) != null
+ && lyricsList.getStructuredLyrics().get(0).getLine() != null
+ && !lyricsList.getStructuredLyrics().get(0).getLine().isEmpty();
+ }
+
+ private boolean shouldAutoDownloadLyrics() {
+ return Preferences.isAutoDownloadLyricsEnabled();
+ }
+
+ public boolean downloadCurrentLyrics() {
+ Child media = getLiveMedia().getValue();
+ if (media == null) {
+ return false;
+ }
+
+ LyricsList lyricsList = lyricsListLiveData.getValue();
+ String lyrics = lyricsLiveData.getValue();
+
+ if ((lyricsList == null || !hasStructuredLyrics(lyricsList)) && TextUtils.isEmpty(lyrics)) {
+ return false;
+ }
+
+ saveLyricsToCache(media, lyrics, lyricsList);
+ return true;
+ }
+
+ public LiveData getLyricsCachedState() {
+ return lyricsCachedLiveData;
+ }
+
public void changeSyncLyricsState() {
lyricsSyncState = !lyricsSyncState;
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java
index 2ec6c21f..ca7af150 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java
@@ -1,6 +1,8 @@
package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
+import android.app.Dialog;
+import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
@@ -11,10 +13,10 @@ import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.repository.PlaylistRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Playlist;
+import com.cappielloantonio.tempo.util.Preferences;
import com.google.common.collect.Lists;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.List;
public class PlaylistChooserViewModel extends AndroidViewModel {
@@ -34,8 +36,21 @@ public class PlaylistChooserViewModel extends AndroidViewModel {
return playlists;
}
- public void addSongsToPlaylist(String playlistId) {
- playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(Lists.transform(toAdd, Child::getId)));
+ public void addSongsToPlaylist(LifecycleOwner owner, Dialog dialog, String playlistId) {
+ List songIds = Lists.transform(toAdd, Child::getId);
+ if (Preferences.allowPlaylistDuplicates()) {
+ playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds));
+ dialog.dismiss();
+ } else {
+ playlistRepository.getPlaylistSongs(playlistId).observe(owner, playlistSongs -> {
+ if (playlistSongs != null) {
+ List playlistSongIds = Lists.transform(playlistSongs, Child::getId);
+ songIds.removeAll(playlistSongIds);
+ }
+ playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds));
+ dialog.dismiss();
+ });
+ }
}
public void setSongsToAdd(ArrayList songs) {
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java
index 79f1655b..de379dcd 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java
@@ -109,7 +109,7 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
media.setStarred(new Date());
- if (Preferences.isStarredSyncEnabled()) {
+ if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownload(media),
new Download(media)
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/StarredArtistsSyncViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/StarredArtistsSyncViewModel.java
new file mode 100644
index 00000000..474cbe87
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/StarredArtistsSyncViewModel.java
@@ -0,0 +1,94 @@
+package com.cappielloantonio.tempo.viewmodel;
+
+import android.app.Application;
+import android.app.Activity;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.Observer;
+import androidx.lifecycle.MutableLiveData;
+
+import com.cappielloantonio.tempo.repository.ArtistRepository;
+import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
+import com.cappielloantonio.tempo.subsonic.models.Child;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class StarredArtistsSyncViewModel extends AndroidViewModel {
+ private final ArtistRepository artistRepository;
+
+ private final MutableLiveData> starredArtists = new MutableLiveData<>(null);
+ private final MutableLiveData> starredArtistSongs = new MutableLiveData<>(null);
+
+ public StarredArtistsSyncViewModel(@NonNull Application application) {
+ super(application);
+ artistRepository = new ArtistRepository();
+ }
+
+ public LiveData> getStarredArtists(LifecycleOwner owner) {
+ artistRepository.getStarredArtists(false, -1).observe(owner, starredArtists::postValue);
+ return starredArtists;
+ }
+
+ public LiveData> getAllStarredArtistSongs() {
+ artistRepository.getStarredArtists(false, -1).observeForever(new Observer>() {
+ @Override
+ public void onChanged(List artists) {
+ if (artists != null && !artists.isEmpty()) {
+ collectAllArtistSongs(artists, starredArtistSongs::postValue);
+ } else {
+ starredArtistSongs.postValue(new ArrayList<>());
+ }
+ artistRepository.getStarredArtists(false, -1).removeObserver(this);
+ }
+ });
+
+ return starredArtistSongs;
+ }
+
+ public LiveData> getStarredArtistSongs(Activity activity) {
+ artistRepository.getStarredArtists(false, -1).observe((LifecycleOwner) activity, artists -> {
+ if (artists != null && !artists.isEmpty()) {
+ collectAllArtistSongs(artists, starredArtistSongs::postValue);
+ } else {
+ starredArtistSongs.postValue(new ArrayList<>());
+ }
+ });
+ return starredArtistSongs;
+ }
+
+ private void collectAllArtistSongs(List artists, ArtistSongsCallback callback) {
+ if (artists == null || artists.isEmpty()) {
+ callback.onSongsCollected(new ArrayList<>());
+ return;
+ }
+
+ List allSongs = new ArrayList<>();
+ AtomicInteger remainingArtists = new AtomicInteger(artists.size());
+
+ for (ArtistID3 artist : artists) {
+ artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() {
+ @Override
+ public void onSongsCollected(List songs) {
+ if (songs != null) {
+ allSongs.addAll(songs);
+ }
+
+ int remaining = remainingArtists.decrementAndGet();
+ if (remaining == 0) {
+ callback.onSongsCollected(allSongs);
+ }
+ }
+ });
+ }
+ }
+
+ private interface ArtistSongsCallback {
+ void onSongsCollected(List songs);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetActions.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetActions.java
new file mode 100644
index 00000000..c6bd8e60
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetActions.java
@@ -0,0 +1,62 @@
+package com.cappielloantonio.tempo.widget;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.util.Log;
+
+import androidx.media3.common.Player;
+import androidx.media3.session.MediaController;
+import androidx.media3.session.SessionToken;
+
+import com.cappielloantonio.tempo.service.MediaService;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import java.util.concurrent.ExecutionException;
+
+public final class WidgetActions {
+ public static void dispatchToMediaSession(Context ctx, String action) {
+ Log.d("TempoWidget", "dispatch action=" + action);
+ Context appCtx = ctx.getApplicationContext();
+ SessionToken token = new SessionToken(appCtx, new ComponentName(appCtx, MediaService.class));
+ ListenableFuture future = new MediaController.Builder(appCtx, token).buildAsync();
+ future.addListener(() -> {
+ try {
+ if (!future.isDone()) return;
+ MediaController c = future.get();
+ Log.d("TempoWidget", "controller connected, isPlaying=" + c.isPlaying());
+ switch (action) {
+ case WidgetProvider.ACT_PLAY_PAUSE:
+ if (c.isPlaying()) c.pause();
+ else c.play();
+ break;
+ case WidgetProvider.ACT_NEXT:
+ c.seekToNext();
+ break;
+ case WidgetProvider.ACT_PREV:
+ c.seekToPrevious();
+ break;
+ case WidgetProvider.ACT_TOGGLE_SHUFFLE:
+ c.setShuffleModeEnabled(!c.getShuffleModeEnabled());
+ break;
+ case WidgetProvider.ACT_CYCLE_REPEAT:
+ int repeatMode = c.getRepeatMode();
+ int nextMode;
+ if (repeatMode == Player.REPEAT_MODE_OFF) {
+ nextMode = Player.REPEAT_MODE_ALL;
+ } else if (repeatMode == Player.REPEAT_MODE_ALL) {
+ nextMode = Player.REPEAT_MODE_ONE;
+ } else {
+ nextMode = Player.REPEAT_MODE_OFF;
+ }
+ c.setRepeatMode(nextMode);
+ break;
+ }
+ WidgetUpdateManager.refreshFromController(ctx);
+ c.release();
+ } catch (ExecutionException | InterruptedException e) {
+ Log.e("TempoWidget", "dispatch failed", e);
+ }
+ }, MoreExecutors.directExecutor());
+ }
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java
new file mode 100644
index 00000000..93a1a7e2
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java
@@ -0,0 +1,137 @@
+package com.cappielloantonio.tempo.widget;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.widget.RemoteViews;
+
+import com.cappielloantonio.tempo.R;
+
+import android.app.TaskStackBuilder;
+
+import com.cappielloantonio.tempo.ui.activity.MainActivity;
+
+import android.util.Log;
+
+import androidx.annotation.Nullable;
+
+public class WidgetProvider extends AppWidgetProvider {
+ private static final String TAG = "TempoWidget";
+ public static final String ACT_PLAY_PAUSE = "tempo.widget.PLAY_PAUSE";
+ public static final String ACT_NEXT = "tempo.widget.NEXT";
+ public static final String ACT_PREV = "tempo.widget.PREV";
+ public static final String ACT_TOGGLE_SHUFFLE = "tempo.widget.SHUFFLE";
+ public static final String ACT_CYCLE_REPEAT = "tempo.widget.REPEAT";
+
+ @Override
+ public void onUpdate(Context ctx, AppWidgetManager mgr, int[] ids) {
+ for (int id : ids) {
+ RemoteViews rv = WidgetUpdateManager.chooseBuild(ctx, id);
+ attachIntents(ctx, rv, id, null, null, null);
+ mgr.updateAppWidget(id, rv);
+ }
+ }
+
+ @Override
+ public void onReceive(Context ctx, Intent intent) {
+ super.onReceive(ctx, intent);
+ String a = intent.getAction();
+ Log.d(TAG, "onReceive action=" + a);
+ if (ACT_PLAY_PAUSE.equals(a) || ACT_NEXT.equals(a) || ACT_PREV.equals(a)
+ || ACT_TOGGLE_SHUFFLE.equals(a) || ACT_CYCLE_REPEAT.equals(a)) {
+ WidgetActions.dispatchToMediaSession(ctx, a);
+ } else if (AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(a)) {
+ WidgetUpdateManager.refreshFromController(ctx);
+ }
+ }
+
+ @Override
+ public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, android.os.Bundle newOptions) {
+ super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
+ RemoteViews rv = WidgetUpdateManager.chooseBuild(context, appWidgetId);
+ attachIntents(context, rv, appWidgetId, null, null, null);
+ appWidgetManager.updateAppWidget(appWidgetId, rv);
+ WidgetUpdateManager.refreshFromController(context);
+ }
+
+ public static void attachIntents(Context ctx, RemoteViews rv) {
+ attachIntents(ctx, rv, 0, null, null, null);
+ }
+
+ public static void attachIntents(Context ctx, RemoteViews rv, int requestCodeBase) {
+ attachIntents(ctx, rv, requestCodeBase, null, null, null);
+ }
+
+ public static void attachIntents(Context ctx, RemoteViews rv, int requestCodeBase,
+ String songLink,
+ String albumLink,
+ String artistLink) {
+ PendingIntent playPause = PendingIntent.getBroadcast(
+ ctx,
+ requestCodeBase + 0,
+ new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_PLAY_PAUSE),
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+ );
+ PendingIntent next = PendingIntent.getBroadcast(
+ ctx,
+ requestCodeBase + 1,
+ new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_NEXT),
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+ );
+ PendingIntent prev = PendingIntent.getBroadcast(
+ ctx,
+ requestCodeBase + 2,
+ new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_PREV),
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+ );
+ PendingIntent shuffle = PendingIntent.getBroadcast(
+ ctx,
+ requestCodeBase + 3,
+ new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_TOGGLE_SHUFFLE),
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+ );
+ PendingIntent repeat = PendingIntent.getBroadcast(
+ ctx,
+ requestCodeBase + 4,
+ new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_CYCLE_REPEAT),
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+ );
+
+ rv.setOnClickPendingIntent(R.id.btn_play_pause, playPause);
+ rv.setOnClickPendingIntent(R.id.btn_next, next);
+ rv.setOnClickPendingIntent(R.id.btn_prev, prev);
+ rv.setOnClickPendingIntent(R.id.btn_shuffle, shuffle);
+ rv.setOnClickPendingIntent(R.id.btn_repeat, repeat);
+
+ PendingIntent launch = buildMainActivityPendingIntent(ctx, requestCodeBase + 10, null);
+ rv.setOnClickPendingIntent(R.id.root, launch);
+
+ PendingIntent songPending = buildMainActivityPendingIntent(ctx, requestCodeBase + 20, songLink);
+ PendingIntent artistPending = buildMainActivityPendingIntent(ctx, requestCodeBase + 21, artistLink);
+ PendingIntent albumPending = buildMainActivityPendingIntent(ctx, requestCodeBase + 22, albumLink);
+
+ PendingIntent fallback = launch;
+ rv.setOnClickPendingIntent(R.id.album_art, songPending != null ? songPending : fallback);
+ rv.setOnClickPendingIntent(R.id.title, songPending != null ? songPending : fallback);
+ rv.setOnClickPendingIntent(R.id.subtitle,
+ artistPending != null ? artistPending : (songPending != null ? songPending : fallback));
+ rv.setOnClickPendingIntent(R.id.album, albumPending != null ? albumPending : fallback);
+ }
+
+ private static PendingIntent buildMainActivityPendingIntent(Context ctx, int requestCode, @Nullable String link) {
+ Intent intent;
+ if (!TextUtils.isEmpty(link)) {
+ intent = new Intent(Intent.ACTION_VIEW, Uri.parse(link), ctx, MainActivity.class);
+ } else {
+ intent = new Intent(ctx, MainActivity.class);
+ }
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+ TaskStackBuilder stackBuilder = TaskStackBuilder.create(ctx);
+ stackBuilder.addNextIntentWithParentStack(intent);
+ return stackBuilder.getPendingIntent(requestCode, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider4x1.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider4x1.java
new file mode 100644
index 00000000..b4e5923a
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider4x1.java
@@ -0,0 +1,9 @@
+package com.cappielloantonio.tempo.widget;
+
+/**
+ * AppWidget provider entry for the 4x1 widget card. Inherits all behavior
+ * from {@link WidgetProvider}.
+ */
+public class WidgetProvider4x1 extends WidgetProvider {
+}
+
diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java
new file mode 100644
index 00000000..f159c526
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java
@@ -0,0 +1,309 @@
+package com.cappielloantonio.tempo.widget;
+
+import android.appwidget.AppWidgetManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.text.TextUtils;
+
+import com.bumptech.glide.request.target.CustomTarget;
+import com.bumptech.glide.request.transition.Transition;
+import com.cappielloantonio.tempo.glide.CustomGlideRequest;
+import com.cappielloantonio.tempo.R;
+
+import androidx.media3.common.C;
+import androidx.media3.session.MediaController;
+import androidx.media3.session.SessionToken;
+
+import com.cappielloantonio.tempo.service.MediaService;
+import com.cappielloantonio.tempo.util.AssetLinkUtil;
+import com.cappielloantonio.tempo.util.MusicUtil;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import java.util.concurrent.ExecutionException;
+
+public final class WidgetUpdateManager {
+
+ private static final int WIDGET_SAFE_ART_SIZE = 512;
+
+ public static void updateFromState(Context ctx,
+ String title,
+ String artist,
+ String album,
+ Bitmap art,
+ boolean playing,
+ boolean shuffleEnabled,
+ int repeatMode,
+ long positionMs,
+ long durationMs,
+ String songLink,
+ String albumLink,
+ String artistLink) {
+ if (TextUtils.isEmpty(title)) title = ctx.getString(R.string.widget_not_playing);
+ if (TextUtils.isEmpty(artist)) artist = ctx.getString(R.string.widget_placeholder_subtitle);
+ if (TextUtils.isEmpty(album)) album = "";
+
+ final TimingInfo timing = createTimingInfo(positionMs, durationMs);
+
+ AppWidgetManager mgr = AppWidgetManager.getInstance(ctx);
+ int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class));
+ for (int id : ids) {
+ android.widget.RemoteViews rv = choosePopulate(ctx, title, artist, album, art, playing,
+ timing.elapsedText, timing.totalText, timing.progress, shuffleEnabled, repeatMode, id);
+ WidgetProvider.attachIntents(ctx, rv, id, songLink, albumLink, artistLink);
+ mgr.updateAppWidget(id, rv);
+ }
+ }
+
+ public static void pushNow(Context ctx) {
+ AppWidgetManager mgr = AppWidgetManager.getInstance(ctx);
+ int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class));
+ for (int id : ids) {
+ android.widget.RemoteViews rv = chooseBuild(ctx, id);
+ WidgetProvider.attachIntents(ctx, rv, id, null, null, null);
+ mgr.updateAppWidget(id, rv);
+ }
+ }
+
+ public static void updateFromState(Context ctx,
+ String title,
+ String artist,
+ String album,
+ String coverArtId,
+ boolean playing,
+ boolean shuffleEnabled,
+ int repeatMode,
+ long positionMs,
+ long durationMs,
+ String songLink,
+ String albumLink,
+ String artistLink) {
+ final Context appCtx = ctx.getApplicationContext();
+ final String t = TextUtils.isEmpty(title) ? appCtx.getString(R.string.widget_not_playing) : title;
+ final String a = TextUtils.isEmpty(artist) ? appCtx.getString(R.string.widget_placeholder_subtitle) : artist;
+ final String alb = !TextUtils.isEmpty(album) ? album : "";
+ final boolean p = playing;
+ final boolean sh = shuffleEnabled;
+ final int rep = repeatMode;
+ final TimingInfo timing = createTimingInfo(positionMs, durationMs);
+ final String songLinkFinal = songLink;
+ final String albumLinkFinal = albumLink;
+ final String artistLinkFinal = artistLink;
+
+ if (!TextUtils.isEmpty(coverArtId)) {
+ CustomGlideRequest.loadAlbumArtBitmap(
+ appCtx,
+ coverArtId,
+ WIDGET_SAFE_ART_SIZE,
+ new CustomTarget() {
+ @Override
+ public void onResourceReady(Bitmap resource, Transition super Bitmap> transition) {
+ AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx);
+ int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class));
+ for (int id : ids) {
+ android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, resource, p,
+ timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
+ WidgetProvider.attachIntents(appCtx, rv, id, songLinkFinal, albumLinkFinal, artistLinkFinal);
+ mgr.updateAppWidget(id, rv);
+ }
+ }
+
+ @Override
+ public void onLoadCleared(Drawable placeholder) {
+ AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx);
+ int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class));
+ for (int id : ids) {
+ android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p,
+ timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
+ WidgetProvider.attachIntents(appCtx, rv, id, songLinkFinal, albumLinkFinal, artistLinkFinal);
+ mgr.updateAppWidget(id, rv);
+ }
+ }
+ }
+ );
+ } else {
+ AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx);
+ int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class));
+ for (int id : ids) {
+ android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p,
+ timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
+ WidgetProvider.attachIntents(appCtx, rv, id, songLinkFinal, albumLinkFinal, artistLinkFinal);
+ mgr.updateAppWidget(id, rv);
+ }
+ }
+ }
+
+ public static void refreshFromController(Context ctx) {
+ final Context appCtx = ctx.getApplicationContext();
+ SessionToken token = new SessionToken(appCtx, new ComponentName(appCtx, MediaService.class));
+ ListenableFuture future = new MediaController.Builder(appCtx, token).buildAsync();
+ future.addListener(() -> {
+ try {
+ if (!future.isDone()) return;
+ MediaController c = future.get();
+ androidx.media3.common.MediaItem mi = c.getCurrentMediaItem();
+ String title = null, artist = null, album = null, coverId = null;
+ String songLink = null, albumLink = null, artistLink = null;
+ if (mi != null && mi.mediaMetadata != null) {
+ if (mi.mediaMetadata.title != null) title = mi.mediaMetadata.title.toString();
+ if (mi.mediaMetadata.artist != null)
+ artist = mi.mediaMetadata.artist.toString();
+ if (mi.mediaMetadata.albumTitle != null)
+ album = mi.mediaMetadata.albumTitle.toString();
+ if (mi.mediaMetadata.extras != null) {
+ Bundle extras = mi.mediaMetadata.extras;
+ if (title == null) title = mi.mediaMetadata.extras.getString("title");
+ if (artist == null) artist = mi.mediaMetadata.extras.getString("artist");
+ if (album == null) album = mi.mediaMetadata.extras.getString("album");
+ coverId = extras.getString("coverArtId");
+
+ songLink = extras.getString("assetLinkSong");
+ if (songLink == null) {
+ songLink = AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras.getString("id"));
+ }
+
+ albumLink = extras.getString("assetLinkAlbum");
+ if (albumLink == null) {
+ albumLink = AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras.getString("albumId"));
+ }
+
+ artistLink = extras.getString("assetLinkArtist");
+ if (artistLink == null) {
+ artistLink = AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras.getString("artistId"));
+ }
+ }
+ }
+ long position = c.getCurrentPosition();
+ long duration = c.getDuration();
+ if (position == C.TIME_UNSET) position = 0;
+ if (duration == C.TIME_UNSET) duration = 0;
+ updateFromState(appCtx,
+ title != null ? title : appCtx.getString(R.string.widget_not_playing),
+ artist != null ? artist : appCtx.getString(R.string.widget_placeholder_subtitle),
+ album,
+ coverId,
+ c.isPlaying(),
+ c.getShuffleModeEnabled(),
+ c.getRepeatMode(),
+ position,
+ duration,
+ songLink,
+ albumLink,
+ artistLink);
+ c.release();
+ } catch (ExecutionException | InterruptedException ignored) {
+ }
+ }, MoreExecutors.directExecutor());
+ }
+
+ private static TimingInfo createTimingInfo(long positionMs, long durationMs) {
+ long safePosition = Math.max(0L, positionMs);
+ long safeDuration = durationMs > 0 ? durationMs : 0L;
+ if (safeDuration > 0 && safePosition > safeDuration) {
+ safePosition = safeDuration;
+ }
+
+ String elapsed = (safeDuration > 0 || safePosition > 0)
+ ? MusicUtil.getReadableDurationString(safePosition, true)
+ : null;
+ String total = safeDuration > 0
+ ? MusicUtil.getReadableDurationString(safeDuration, true)
+ : null;
+
+ int progress = 0;
+ if (safeDuration > 0) {
+ long scaled = safePosition * WidgetViewsFactory.PROGRESS_MAX;
+ long progressLong = scaled / safeDuration;
+ if (progressLong < 0) {
+ progress = 0;
+ } else if (progressLong > WidgetViewsFactory.PROGRESS_MAX) {
+ progress = WidgetViewsFactory.PROGRESS_MAX;
+ } else {
+ progress = (int) progressLong;
+ }
+ }
+
+ return new TimingInfo(elapsed, total, progress);
+ }
+
+ public static android.widget.RemoteViews chooseBuild(Context ctx, int appWidgetId) {
+ LayoutSize size = resolveLayoutSize(ctx, appWidgetId);
+ switch (size) {
+ case MEDIUM:
+ return WidgetViewsFactory.buildMedium(ctx);
+ case LARGE:
+ return WidgetViewsFactory.buildLarge(ctx);
+ case EXPANDED:
+ return WidgetViewsFactory.buildExpanded(ctx);
+ case COMPACT:
+ default:
+ return WidgetViewsFactory.buildCompact(ctx);
+ }
+ }
+
+ private static android.widget.RemoteViews choosePopulate(Context ctx,
+ String title,
+ String artist,
+ String album,
+ Bitmap art,
+ boolean playing,
+ String elapsedText,
+ String totalText,
+ int progress,
+ boolean shuffleEnabled,
+ int repeatMode,
+ int appWidgetId) {
+ LayoutSize size = resolveLayoutSize(ctx, appWidgetId);
+ switch (size) {
+ case MEDIUM:
+ return WidgetViewsFactory.populateMedium(ctx, title, artist, album, art, playing,
+ elapsedText, totalText, progress, shuffleEnabled, repeatMode);
+ case LARGE:
+ return WidgetViewsFactory.populateLarge(ctx, title, artist, album, art, playing,
+ elapsedText, totalText, progress, shuffleEnabled, repeatMode);
+ case EXPANDED:
+ return WidgetViewsFactory.populateExpanded(ctx, title, artist, album, art, playing,
+ elapsedText, totalText, progress, shuffleEnabled, repeatMode);
+ case COMPACT:
+ default:
+ return WidgetViewsFactory.populateCompact(ctx, title, artist, album, art, playing,
+ elapsedText, totalText, progress, shuffleEnabled, repeatMode);
+ }
+ }
+
+ private static LayoutSize resolveLayoutSize(Context ctx, int appWidgetId) {
+ AppWidgetManager mgr = AppWidgetManager.getInstance(ctx);
+ android.os.Bundle opts = mgr.getAppWidgetOptions(appWidgetId);
+ int minH = opts != null ? opts.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT) : 0;
+ int expandedThreshold = ctx.getResources().getInteger(R.integer.widget_expanded_min_height_dp);
+ int largeThreshold = ctx.getResources().getInteger(R.integer.widget_large_min_height_dp);
+ int mediumThreshold = ctx.getResources().getInteger(R.integer.widget_medium_min_height_dp);
+ if (minH >= expandedThreshold) return LayoutSize.EXPANDED;
+ if (minH >= largeThreshold) return LayoutSize.LARGE;
+ if (minH >= mediumThreshold) return LayoutSize.MEDIUM;
+ return LayoutSize.COMPACT;
+ }
+
+ private enum LayoutSize {
+ COMPACT,
+ MEDIUM,
+ LARGE,
+ EXPANDED
+ }
+
+ private static final class TimingInfo {
+ final String elapsedText;
+ final String totalText;
+ final int progress;
+
+ TimingInfo(String elapsedText, String totalText, int progress) {
+ this.elapsedText = elapsedText;
+ this.totalText = totalText;
+ this.progress = progress;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java
new file mode 100644
index 00000000..c66fd1cb
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java
@@ -0,0 +1,252 @@
+package com.cappielloantonio.tempo.widget;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapShader;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.graphics.Shader;
+import android.text.TextUtils;
+import android.util.TypedValue;
+import android.view.View;
+import android.widget.RemoteViews;
+
+import androidx.core.content.ContextCompat;
+import androidx.media3.common.Player;
+
+import com.cappielloantonio.tempo.R;
+
+public final class WidgetViewsFactory {
+
+ static final int PROGRESS_MAX = 1000;
+ private static final float ALBUM_ART_CORNER_RADIUS_DP = 6f;
+
+ private WidgetViewsFactory() {
+ }
+
+ public static RemoteViews buildCompact(Context ctx) {
+ return build(ctx, R.layout.widget_layout_compact, false, false);
+ }
+
+ public static RemoteViews buildMedium(Context ctx) {
+ return build(ctx, R.layout.widget_layout_medium, false, false);
+ }
+
+ public static RemoteViews buildLarge(Context ctx) {
+ return build(ctx, R.layout.widget_layout_large_short, true, true);
+ }
+
+ public static RemoteViews buildExpanded(Context ctx) {
+ return build(ctx, R.layout.widget_layout_large, true, true);
+ }
+
+ private static RemoteViews build(Context ctx,
+ int layoutRes,
+ boolean showAlbum,
+ boolean showSecondaryControls) {
+ RemoteViews rv = new RemoteViews(ctx.getPackageName(), layoutRes);
+ rv.setTextViewText(R.id.title, ctx.getString(R.string.widget_not_playing));
+ rv.setTextViewText(R.id.subtitle, ctx.getString(R.string.widget_placeholder_subtitle));
+ rv.setTextViewText(R.id.album, "");
+ rv.setViewVisibility(R.id.album, showAlbum ? View.INVISIBLE : View.GONE);
+ rv.setTextViewText(R.id.time_elapsed, ctx.getString(R.string.widget_time_elapsed_placeholder));
+ rv.setTextViewText(R.id.time_total, ctx.getString(R.string.widget_time_duration_placeholder));
+ rv.setProgressBar(R.id.progress, PROGRESS_MAX, 0, false);
+ rv.setImageViewResource(R.id.btn_play_pause, R.drawable.ic_play);
+ rv.setImageViewResource(R.id.album_art, R.drawable.ic_splash_logo);
+ applySecondaryControlsDefaults(ctx, rv, showSecondaryControls);
+ return rv;
+ }
+
+ private static void applySecondaryControlsDefaults(Context ctx,
+ RemoteViews rv,
+ boolean show) {
+ int visibility = show ? View.VISIBLE : View.GONE;
+ rv.setViewVisibility(R.id.controls_secondary, visibility);
+ rv.setViewVisibility(R.id.btn_shuffle, visibility);
+ rv.setViewVisibility(R.id.btn_repeat, visibility);
+ if (show) {
+ int defaultColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint);
+ rv.setImageViewResource(R.id.btn_shuffle, R.drawable.ic_shuffle);
+ rv.setImageViewResource(R.id.btn_repeat, R.drawable.ic_repeat);
+ rv.setInt(R.id.btn_shuffle, "setColorFilter", defaultColor);
+ rv.setInt(R.id.btn_repeat, "setColorFilter", defaultColor);
+ }
+ }
+
+ public static RemoteViews populateCompact(Context ctx,
+ String title,
+ String subtitle,
+ String album,
+ Bitmap art,
+ boolean playing,
+ String elapsedText,
+ String totalText,
+ int progress,
+ boolean shuffleEnabled,
+ int repeatMode) {
+ return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
+ progress, R.layout.widget_layout_compact, false, false, shuffleEnabled, repeatMode);
+ }
+
+ public static RemoteViews populateMedium(Context ctx,
+ String title,
+ String subtitle,
+ String album,
+ Bitmap art,
+ boolean playing,
+ String elapsedText,
+ String totalText,
+ int progress,
+ boolean shuffleEnabled,
+ int repeatMode) {
+ return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
+ progress, R.layout.widget_layout_medium, true, true, shuffleEnabled, repeatMode);
+ }
+
+ public static RemoteViews populateLarge(Context ctx,
+ String title,
+ String subtitle,
+ String album,
+ Bitmap art,
+ boolean playing,
+ String elapsedText,
+ String totalText,
+ int progress,
+ boolean shuffleEnabled,
+ int repeatMode) {
+ return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
+ progress, R.layout.widget_layout_large_short, true, true, shuffleEnabled, repeatMode);
+ }
+
+ public static RemoteViews populateExpanded(Context ctx,
+ String title,
+ String subtitle,
+ String album,
+ Bitmap art,
+ boolean playing,
+ String elapsedText,
+ String totalText,
+ int progress,
+ boolean shuffleEnabled,
+ int repeatMode) {
+ return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
+ progress, R.layout.widget_layout_large, true, true, shuffleEnabled, repeatMode);
+ }
+
+ private static RemoteViews populateWithLayout(Context ctx,
+ String title,
+ String subtitle,
+ String album,
+ Bitmap art,
+ boolean playing,
+ String elapsedText,
+ String totalText,
+ int progress,
+ int layoutRes,
+ boolean showAlbum,
+ boolean showSecondaryControls,
+ boolean shuffleEnabled,
+ int repeatMode) {
+ RemoteViews rv = new RemoteViews(ctx.getPackageName(), layoutRes);
+ rv.setTextViewText(R.id.title, title);
+ rv.setTextViewText(R.id.subtitle, subtitle);
+
+ if (showAlbum && !TextUtils.isEmpty(album)) {
+ rv.setTextViewText(R.id.album, album);
+ rv.setViewVisibility(R.id.album, View.VISIBLE);
+ } else {
+ rv.setTextViewText(R.id.album, "");
+ rv.setViewVisibility(R.id.album, View.GONE);
+ }
+
+ if (art != null) {
+ Bitmap rounded = maybeRoundBitmap(ctx, art);
+ rv.setImageViewBitmap(R.id.album_art, rounded != null ? rounded : art);
+ } else {
+ rv.setImageViewResource(R.id.album_art, R.drawable.ic_splash_logo);
+ }
+
+ rv.setImageViewResource(R.id.btn_play_pause,
+ playing ? R.drawable.ic_pause : R.drawable.ic_play);
+
+ String elapsed = !TextUtils.isEmpty(elapsedText)
+ ? elapsedText
+ : ctx.getString(R.string.widget_time_elapsed_placeholder);
+ String total = !TextUtils.isEmpty(totalText)
+ ? totalText
+ : ctx.getString(R.string.widget_time_duration_placeholder);
+
+ int safeProgress = progress;
+ if (safeProgress < 0) safeProgress = 0;
+ if (safeProgress > PROGRESS_MAX) safeProgress = PROGRESS_MAX;
+
+ rv.setTextViewText(R.id.time_elapsed, elapsed);
+ rv.setTextViewText(R.id.time_total, total);
+ rv.setProgressBar(R.id.progress, PROGRESS_MAX, safeProgress, false);
+
+ applySecondaryControls(ctx, rv, showSecondaryControls, shuffleEnabled, repeatMode);
+
+ return rv;
+ }
+
+ private static Bitmap maybeRoundBitmap(Context ctx, Bitmap source) {
+ if (source == null || source.isRecycled()) {
+ return null;
+ }
+
+ try {
+ int width = source.getWidth();
+ int height = source.getHeight();
+ if (width <= 0 || height <= 0) {
+ return null;
+ }
+
+ Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(output);
+
+ Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ paint.setShader(new BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
+
+ float radiusPx = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ ALBUM_ART_CORNER_RADIUS_DP,
+ ctx.getResources().getDisplayMetrics());
+ float maxRadius = Math.min(width, height) / 2f;
+ float safeRadius = Math.min(radiusPx, maxRadius);
+
+ canvas.drawRoundRect(new RectF(0f, 0f, width, height), safeRadius, safeRadius, paint);
+ return output;
+ } catch (RuntimeException | OutOfMemoryError e) {
+ android.util.Log.w("TempoWidget", "Failed to round album art", e);
+ return null;
+ }
+ }
+
+ private static void applySecondaryControls(Context ctx,
+ RemoteViews rv,
+ boolean show,
+ boolean shuffleEnabled,
+ int repeatMode) {
+ if (!show) {
+ rv.setViewVisibility(R.id.controls_secondary, View.GONE);
+ rv.setViewVisibility(R.id.btn_shuffle, View.GONE);
+ rv.setViewVisibility(R.id.btn_repeat, View.GONE);
+ return;
+ }
+
+ int inactiveColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint);
+ int activeColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint_active);
+
+ rv.setViewVisibility(R.id.controls_secondary, View.VISIBLE);
+ rv.setViewVisibility(R.id.btn_shuffle, View.VISIBLE);
+ rv.setViewVisibility(R.id.btn_repeat, View.VISIBLE);
+ rv.setImageViewResource(R.id.btn_shuffle, R.drawable.ic_shuffle);
+ rv.setImageViewResource(R.id.btn_repeat,
+ repeatMode == Player.REPEAT_MODE_ONE ? R.drawable.ic_repeat_one : R.drawable.ic_repeat);
+ rv.setInt(R.id.btn_shuffle, "setColorFilter", shuffleEnabled ? activeColor : inactiveColor);
+ rv.setInt(R.id.btn_repeat, "setColorFilter",
+ repeatMode == Player.REPEAT_MODE_OFF ? inactiveColor : activeColor);
+ }
+}
diff --git a/app/src/main/res/drawable/ic_eq.xml b/app/src/main/res/drawable/ic_eq.xml
new file mode 100644
index 00000000..5f3a8b46
--- /dev/null
+++ b/app/src/main/res/drawable/ic_eq.xml
@@ -0,0 +1,11 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_folder.xml b/app/src/main/res/drawable/ic_folder.xml
new file mode 100644
index 00000000..2c6f69f5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_folder.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml
new file mode 100644
index 00000000..5592db28
--- /dev/null
+++ b/app/src/main/res/drawable/ic_link.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml
new file mode 100644
index 00000000..f3dcb53a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_refresh.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_repeat_one.xml b/app/src/main/res/drawable/ic_repeat_one.xml
new file mode 100644
index 00000000..f422f79a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_repeat_one.xml
@@ -0,0 +1,12 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ui_eq_not_supported.xml b/app/src/main/res/drawable/ui_eq_not_supported.xml
new file mode 100644
index 00000000..fc8a364b
--- /dev/null
+++ b/app/src/main/res/drawable/ui_eq_not_supported.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/widget_bg.xml b/app/src/main/res/drawable/widget_bg.xml
new file mode 100644
index 00000000..c569bbeb
--- /dev/null
+++ b/app/src/main/res/drawable/widget_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml b/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml
index 7ad0250e..cb3ae9c6 100644
--- a/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml
+++ b/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml
@@ -382,11 +382,23 @@
android:layout_height="wrap_content"
android:padding="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
- app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintStart_toEndOf="@+id/player_open_equalizer_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:srcCompat="@drawable/ic_queue" />
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/bottom_sheet_song_dialog.xml b/app/src/main/res/layout/bottom_sheet_song_dialog.xml
index 2105f941..7cf98440 100644
--- a/app/src/main/res/layout/bottom_sheet_song_dialog.xml
+++ b/app/src/main/res/layout/bottom_sheet_song_dialog.xml
@@ -68,6 +68,14 @@
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/dialog_playlist_chooser.xml b/app/src/main/res/layout/dialog_playlist_chooser.xml
index 80b4bfbe..3be03136 100644
--- a/app/src/main/res/layout/dialog_playlist_chooser.xml
+++ b/app/src/main/res/layout/dialog_playlist_chooser.xml
@@ -19,7 +19,8 @@
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_starred_artist_sync.xml b/app/src/main/res/layout/dialog_starred_artist_sync.xml
new file mode 100644
index 00000000..ca41742e
--- /dev/null
+++ b/app/src/main/res/layout/dialog_starred_artist_sync.xml
@@ -0,0 +1,14 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_album_catalogue.xml b/app/src/main/res/layout/fragment_album_catalogue.xml
index c0928866..71977634 100644
--- a/app/src/main/res/layout/fragment_album_catalogue.xml
+++ b/app/src/main/res/layout/fragment_album_catalogue.xml
@@ -41,23 +41,40 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
-
+ app:layout_constraintEnd_toEndOf="parent">
+
+
+
+
+
+
-
@@ -87,4 +103,3 @@
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
-
diff --git a/app/src/main/res/layout/fragment_download.xml b/app/src/main/res/layout/fragment_download.xml
index 0a454508..68d3f84b 100644
--- a/app/src/main/res/layout/fragment_download.xml
+++ b/app/src/main/res/layout/fragment_download.xml
@@ -80,7 +80,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/download_title_section"
- app:layout_constraintEnd_toStartOf="@+id/downloaded_go_back_image_view"
+ app:layout_constraintEnd_toStartOf="@+id/downloaded_refresh_image_view"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@@ -94,6 +94,19 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/downloaded_text_view_refreshable"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_home_tab_music.xml b/app/src/main/res/layout/fragment_home_tab_music.xml
index e9811da3..c516171c 100644
--- a/app/src/main/res/layout/fragment_home_tab_music.xml
+++ b/app/src/main/res/layout/fragment_home_tab_music.xml
@@ -198,6 +198,98 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ app:layout_constraintTop_toBottomOf="@+id/player_asset_link_row" />
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/res/layout/inner_fragment_player_lyrics.xml b/app/src/main/res/layout/inner_fragment_player_lyrics.xml
index f9b5ba48..5ece8996 100644
--- a/app/src/main/res/layout/inner_fragment_player_lyrics.xml
+++ b/app/src/main/res/layout/inner_fragment_player_lyrics.xml
@@ -51,7 +51,25 @@
app:layout_constraintTop_toTopOf="parent" />
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_layout_compact.xml b/app/src/main/res/layout/widget_layout_compact.xml
new file mode 100644
index 00000000..78fb72fb
--- /dev/null
+++ b/app/src/main/res/layout/widget_layout_compact.xml
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_layout_large.xml b/app/src/main/res/layout/widget_layout_large.xml
new file mode 100644
index 00000000..70c626bb
--- /dev/null
+++ b/app/src/main/res/layout/widget_layout_large.xml
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_layout_large_short.xml b/app/src/main/res/layout/widget_layout_large_short.xml
new file mode 100644
index 00000000..6a715f6e
--- /dev/null
+++ b/app/src/main/res/layout/widget_layout_large_short.xml
@@ -0,0 +1,198 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_layout_medium.xml b/app/src/main/res/layout/widget_layout_medium.xml
new file mode 100644
index 00000000..802da828
--- /dev/null
+++ b/app/src/main/res/layout/widget_layout_medium.xml
@@ -0,0 +1,216 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_compact.xml b/app/src/main/res/layout/widget_preview_compact.xml
new file mode 100644
index 00000000..e863603a
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_compact.xml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/download_popup_menu.xml b/app/src/main/res/menu/download_popup_menu.xml
index 16e0b537..6607d22a 100644
--- a/app/src/main/res/menu/download_popup_menu.xml
+++ b/app/src/main/res/menu/download_popup_menu.xml
@@ -16,4 +16,7 @@
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml
index f1567f95..85681987 100644
--- a/app/src/main/res/navigation/nav_graph.xml
+++ b/app/src/main/res/navigation/nav_graph.xml
@@ -151,6 +151,9 @@
app:destination="@id/loginFragment"
app:popUpTo="@id/homeFragment"
app:popUpToInclusive="true" />
+
+
+
+
+ Gespeicherte Inhalte löschenDownload storage
- Audio Einstellungen anpassen
- Equalizer
+ Audio Einstellungen anpassen
+ System-Equalizerhttps://github.com/eddyizm/tempoVerfolge die EntwicklungGithub
diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml
index 13439c7c..b486fb69 100644
--- a/app/src/main/res/values-es-rES/strings.xml
+++ b/app/src/main/res/values-es-rES/strings.xml
@@ -69,6 +69,7 @@
DescargarSe descargarán todas las pistas de esta carpeta. Las pistas en las subcarpetas no se descargarán.Descargar las pistas
+ Indicar ubicación de descargaUna vez que descargues una pista, la encontrarás aquíNo hay descargas%1$s • %2$s elementos
@@ -79,7 +80,10 @@
Selecciona una opción de almacenamientoExternoInterno
+ DirectorioDescargas
+ No se han encontrado descargas que falten
+ Actualizar descargasAñadir a la colaReproducir siguienteEliminar
@@ -88,6 +92,8 @@
ObligatorioSe necesita un prefijo http o httpsDescargas
+ Añadir a favoritos
+ Cargando…Selecciona dos o más filtrosFiltrarFiltrar artistas
@@ -118,6 +124,7 @@
Descargar estas pistas usará una gran cantidad de datosParece que hay algunas pistas destacadas para sincronizarLos álbumes marcados como favoritos estarán disponibles en el modo sin conexión.
+ Has destacado artistas con música que no has descargadoLo mejor deDescubrirTodo en aleatorio
@@ -170,6 +177,8 @@
DescargadoÁlbumArtista
+ Escaneo: hay %1$d pistas
+ Soporte al usuarioResolución de la imagenIdiomaIdioma del sistema
@@ -201,6 +210,7 @@
%1$.2fxLimpiar la cola de reproducciónCola de reproducción guardada
+ La letra no se puede descargarPrioridad del servidorFormato desconocidoTranscodificando
@@ -212,6 +222,7 @@
CrearAñadir a una lista de reproducciónError al añadir a la lista
+ Todas las pistas se han descartado porque están repetidas%1$d pistas • %2$sDuración • %1$sPulsación larga para eliminar
@@ -257,8 +268,9 @@
Buscar pista, artistas o álbumesIntroduzca al menos tres caracteresÁlbumes
- Ajustes de audio
- Ecualizador
+ Establecer la carpeta de descargas
+ Ajustes de audio
+ Ecualizador del sistemaArtistasPistasBaja seguridad
@@ -280,6 +292,7 @@
Tempo es un cliente de música Subsonic ligero y de código abierto, diseñado nativamente para Android.Acerca dePantalla siempre activa
+ Si está habilitada, no se comprobará si hay pistas repetidas cuando se añadan a la lista.Formato de transcodificaciónSi está habilitada, Tempo no descargará la pista con las opciones de transcodificación que aparecen a continuación.Dar prioridad a las opciones del servidor usadas para el streaming en las descargas
@@ -295,6 +308,8 @@
Prioridad a la hora de transcodificar una pistaEstrategia de bufferPara que los cambios surtan efecto, debes reinciar la app.
+ Elige una carpeta para descargar los archivos de música
+ Limpiar la carpeta de descargasPermite que la música siga reproduciéndose una vez que la lista de reproducción ha terminado, reproduciendo pistas similaresReproducción continuaTamaño de la caché de portadas de álbumes
@@ -316,7 +331,9 @@
Si está habilitada, muestra la valoración de la pista como barra de 5 estrellas en la página del control de reproducción.\n\n*Requiere reiniciar la aplicaciónMostrar valoración de los elementosSincronizar cola de reproducción para este usuario
+ Si está habilitada, muestra el botón de reproducción aleatoria y oculta el botón de «Favoritos» en el minirreproductorMostrar emisoras de radio
+ Descargar las letras automáticamente cuando estén disponibles para que se puedan mostrar cuando no hay conexión.Configurar el modo de ganancia de reproducciónEsquinas redondeadasTamaño de las esquinas
@@ -368,6 +385,7 @@
TemaDatosGeneral
+ Lista de reproducciónValoracionesGanancia de reproducciónRastreo de música (scrobble)
@@ -433,6 +451,39 @@
Se ha añadido a la listaMostrar valoración de las pistasSincronizar álbumes favoritos
+ Sincronizar artistas destacados para uso sin conexiónSi está habilitada, los álbumes favoritos se descargarán para uso sin conexión.
+ Sincronizar artistas destacadosDescargar los álbumes favoritos puede consumir una gran cantidad de datos.
+ Ecualizador
+ Restablecer
+ Habilitar
+ No disponible en este dispositivo
+ Ecualizador
+ Abrir el ecualizador integrado
+ Se ha limpiado la carpeta de descargas.
+ Se ha establecido la carpeta de descargas
+ Widget de Tempo
+ En pausa
+ Abrir Tempo
+ 0:00
+ Portada del álbum
+ Reproducir o pausar
+ Siguiente pista
+ Cambiar modo de repetición
+ Activar/desactivar aleatorio
+ Pista anterior
+ Establece una carpeta de descarga para actualizar tus descargas
+ Sincronizar artistas destacados
+ Descargar letras para uso sin conexión
+ Letras descargadas para uso sin conexión
+ Letra guardada para uso sin conexión
+ Permitir añadir pistas repetidas a la lista
+ Participa en las discusiones y el soporte de la comunidad
+ Mostrar el botón «Aleatorio»
+ Descargar automáticamente las letras
+ Descargar los artistas destacados podría consumir una gran cantidad de datos.
+ Si está habilitada, los artistas destacados se descargarán para uso sin conexión.
+ 0:00
+ Eliminar de favoritos
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 8c6f2d99..45e73574 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -117,6 +117,8 @@
TéléchargerTélécharger ces titres peut entraîner une utilisation importante de donnéesOn dirait qu\'il y a des titres favoris à synchroniser
+ Synchroniser les albums favoris
+ Les albums marqués d\'une étoile seront disponibles hors-ligneBest ofDécouverteTout mélanger
@@ -294,8 +296,8 @@
Continuer entraînera la suppression irréversible de tous les éléments sauvegardés.Supprimer les éléments sauvegardésStockage des téléchargements
- Ajuster les paramètres audios
- Égaliseur
+ Ajuster les paramètres audios
+ Égaliseur du systèmehttps://github.com/eddyizm/tempoSuivre le développementGithub
@@ -341,7 +343,9 @@
%1$s \nUtilisé actuellement : %2$s MiBLe mode de transcodage à prioriser. Si réglé sur \"Lecture directe\", le débit binaire du fichier ne sera pas modifié.Télécharge les médias transcodés. Si activé, les paramètres de transcodage suivants seront utilisés pour les téléchargements.\n\n Si le format de transcodage est reglé à \"Téléchargement direct\", le débit binaire du fichier ne sera pas modifé.
- Quand le fichier est transcodé à la volé, en général, le client n\'affiche pas la durée de la piste. Il est possible de demander aux serveurs qui le supportent d\'estimer la durée de la piste écoutée, mais les temps de réponses peuvent être plus longs.
+ Quand le fichier est transcodé à la volée, en général, le client n\'affiche pas la durée de la piste. Il est possible de demander aux serveurs qui le supportent d\'estimer la durée de la piste écoutée, mais les temps de réponses peuvent être plus longs.
+ Si activé, les albums favoris seront téléchargés pour l\'écoute hors-ligne
+ Synchronisation des albums favoris pour écoute hors-ligneSi activé, les pistes favorites seront téléchargées pour l\'écoute hors-ligneSynchronisation des pistes favorites pour écoute hors-ligneThème
@@ -394,8 +398,10 @@
AnnulerContinuerContinuer et télécharger
- Le téléchargement des titres favoris pourrer utiliser beaucoup de données.
+ Le téléchargement des titres favoris pourrait consommer beaucoup de données.Synchroniser les titres favoris
+ Le téléchargement des titres favoris pourrait consommer beaucoup de données.
+ Synchroniser les albums favorisVeuillez redémarrer l\'app pour appliquer les changements.Modifier le chemin de stockage des fichiers mis en cache risque de provoquer la suppression de tous les fichiers précédemment mis en cache dans le nouvel espace de stockage.Sélectionner une option de stockage
@@ -430,4 +436,14 @@
unDrawUn grand merci à unDraw, nous n\'aurions pas pu rendre cette application aussi belle sans leurs illustrations.https://undraw.co/
+
+ %d album à synchroniser
+ %d albums à synchroniser
+
+ Égaliseur
+ Réinitialiser
+ Activer
+ Non supporté sur cet appareil
+ Égaliseur
+ Ouvrir l\'égaliseur intégré
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index fe9cd76a..64f51d6d 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -282,8 +282,8 @@
Continuando, tutti gli elementi salvati verranno eliminati in modo irreversibile.Elimina elementi salvatiArchivio download
- Regola le impostazioni audio
- Equalizzatore
+ Regola le impostazioni audio
+ Equalizzatore di sistemahttps://github.com/eddyizm/tempoSegui lo sviluppoGithub
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 1953612f..98a99747 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -24,7 +24,9 @@
앨범유사항목 더 보기재생
+ %1$s에 발매, %2$s에 최초 발매됨셔플
+ %1$d 곡 • %2$d 분Tempo탐색 중…인스턴트 믹스
@@ -53,11 +55,14 @@
OKWi-Fi가 연결되지 않은 상태에서 Subsonic 서버에 대한 액세스가 제한되었습니다. 이 경고를 다시 보지 않으려면 앱 설정에서 연결 확인을 비활성화 해주세요.Wi-Fi가 연결되지 않음
+ 셔플취소계속계속할 시 서버에서 다운로드한 모든 저장 항목이 영구적으로 삭제됩니다.저장된 항목 삭제설명 란이 비어있습니다.
+ 디스크 %1$s - %2$s
+ 디스크 %1$s취소다운로드하위 폴더를 제외한 해당 폴더의 모든 트랙이 다운로드됩니다.
@@ -66,9 +71,10 @@
다운로드 하지 않음%1$s • %2$s 항목%1$s 항목
+ 모두 셔플변경 사항을 저장하려면 앱을 다시 시작하세요.
- >다운로드한 파일을 다른 저장소로 변경하면 기존 저장소에서 다운로드한 파일은 즉시 삭제됩니다.
- 저장소 선택 옵션
+ 다운로드한 파일을 다른 저장소로 변경하면 기존 저장소에서 다운로드한 파일은 즉시 삭제됩니다.
+ 저장소 옵션 선택외부내부다운로드
@@ -83,9 +89,11 @@
다운로드둘 이상의 필터를 선택해 주세요.필터
+ 아티스트 필터링장르 필터링장르 카탈로그장르 찾아보기
+ 라디오최애 아티스트의 인기곡좋아하는 음악으로 믹스를 시작해 보세요.새 라디오 추가
@@ -102,11 +110,13 @@
최근 재생모두 보기지난 주
+ 지난 해Made for you가장 많이 재생모두 보기New releases새로운 팟캐스트
+ 재생목록채널모두 보기라디오 스테이션
@@ -120,8 +130,7 @@
★ 즐겨찾기한 트랙모두 보기자주 플레이한 음악
- •
- --
+ 다시 정렬앨범모두 보기아티스트
@@ -138,6 +147,7 @@
추가플레이리스트에 추가모두 다운로드
+ 앨범 평점 매기기다운로드모두다운로드한
@@ -146,13 +156,15 @@
장르트랙년도
- 홈으로
+ 홈
+ 지난 해라이브러리검색
- 셋팅
+ 설정아티스트이름랜덤
+ 홈화면에서 제거년도%1$.2fx재생목록 비우기
@@ -163,10 +175,11 @@
취소생성플레이리스트 추가
- 재생 목록에 노래 추가
- 재생 목록에 노래를 추가하지 못했습니다.
+ 재생 목록에 음악 추가
+ 재생 목록에 음악을 추가하지 못했습니다.%1$d 트랙 • %2$s재생시간 • %1$s
+ 길게 눌러 삭제하기플레이리스트 이름취소삭제
@@ -248,8 +261,8 @@
계속하면 저장된 모든 항목을 완전히 삭제합니다.저장된 항목 삭제스토리지 다운로드
- 오디오 설정 적용
- 이퀄라이저
+ 오디오 설정 적용
+ 시스템 이퀄라이저https://github.com/eddyizm/tempoFollow the developmentGithub
@@ -264,6 +277,7 @@
활성화 시, 음악 디렉터리 섹션을 표시합니다. 폴더 탐색이 제대로 작동하려면 서버가 이 기능을 지원해야 합니다.팟캐스트 보기활성화 시, 팟캐스트 섹션을 표시합니다.
+ 활성화 시 평점과 즐겨찾기 여부가 표시됩니다동기화 타이머활성화 시, 재생목록을 저장하여 재실행 시 상태를 불러올 수 있습니다.사용자의 재생목록 동기화
@@ -276,16 +290,21 @@
활성화 시, 렌더링된 모든 앨범 커버의 곡률 각도를 설정합니다. 다시 시작하면 적용됩니다.라이브러리 스캔음악 스크로블링 활성화
+ 시스템 언어음악 공유 활성화
+ 스트리밍 캐시 저장공간스크로블링은 이 데이터를 수신할 수 있는 서버에 의존합니다.아티스트의 라디오를 들을 때, 인스턴트 믹스를 들을 때, 전체를 셔플할 때 특정 별점 이하의 트랙은 무시됩니다.Replay gain은 일관된 청취 경험을 위해 오디오 트랙의 볼륨 레벨을 조정할 수 있는 기능입니다. 이 설정은 필요한 메타데이터가 트랙에 포함된 경우에만 유효합니다.스크로블링은 기기에서 들은 음악 정보를 음악 서버로 보내는 기능입니다. 이 정보는 음악 선호도에 따른 맞춤 추천을 생성하는 데 사용합니다.링크를 통해 음악을 공유할 수 있습니다. 이 기능은 서버 측에서 지원 및 활성화되어야 하며 개별 트랙, 앨범, 재생 목록으로 제한됩니다.사용자의 재생목록의 상태를 반환합니다. 재생목록의 트랙, 현재 재생 중인 트랙, 트랙 번호가 포함됩니다. 서버가 이 기능을 지원해야 합니다.
+ %1$s \n사용 중: %2$s MiB 트랜스코딩 모드에 우선순위가 부여됩니다. \"직접 재생\"으로 설정하면 파일의 비트 전송률이 변경되지 않습니다.트랜스코딩된 미디어를 다운로드합니다. 활성화하면 다운로드 endpoint를 사용하지 않고 다음 설정이 사용됩니다. \n\n \"다운로드용 트랜스코딩 포맷\"이 \"직접 다운로드\"로 설정된 경우 파일의 비트 전송률은 변경되지 않습니다.파일이 즉시 트랜스코딩되면 일반적으로 트랙 길이를 표시하지 않습니다. 트랙의 재생시간을 추정하는 기능을 지원한다면 서버에 요청할 수 있지만 응답 시간이 필요할 수 있습니다.
+ 활성화 시, 즐겨찾기 앨범을 오프라인으로 사용할 수 있도록 다운로드합니다.
+ 오프라인 사용을 위해 즐겨찾기 앨범 동기화활성화 시, 즐겨찾기 트랙을 오프라인으로 사용할 수 있도록 다운로드합니다.오프라인 사용을 위해 즐겨찾기 트랙 동기화테마
@@ -302,7 +321,6 @@
트랜스코딩 다운로드UI트랜스코딩된 다운로드
- 3.1.0버전모바일 데이터로 스트리밍하려 할 시 확인창을 띄웁니다.Wi-Fi로만 스트리밍 확인창
@@ -351,6 +369,7 @@
재생 시간장르경로
+ 샘플링 레이트크기접미사파일은 Subsonic API를 사용하여 다운로드되었습니다. 파일의 코덱과 비트 전송률은 소스 파일과 동일하게 유지됩니다.
@@ -367,4 +386,42 @@
unDraw이 앱을 일러스트로 더 다채롭게 꾸밀 수 있도록 해준 unDraw 에 특별히 감사드립니다.https://undraw.co/
+ 이 작업은 시간이 소요되며, 다시 시작 후 적용됩니다
+ 음악
+ 팟캐스트
+ 지난 달
+ 지난 주
+ 지난 달
+ 최근에 추가됨
+ 최근에 재생됨
+ 많이 재생됨
+ 즐겨찾기 오래된순
+ 홈화면에 추가
+ %1$s에 발매됨
+ 오디오 품질 표시하기
+ 오디오 트랙에 비트레이트와 포맷이 표시됩니다.
+ 음악 별점 표시하기
+ 스트리밍 캐시 크기
+ 변경 사항을 저장하려면 앱을 다시 시작하세요.
+ 캐시 파일을 다른 저장소로 변경하면 기존의 캐시 파일이 삭제될 수 있습니다.
+ 저장소 옵션 선택
+ 외부
+ 내부
+ 나중에 다시 알려주기
+ 지금 다운로드 받기
+ Github에 최신 버전이 존재합니다
+ 업데이트 가능
+ 취소
+ 초기화
+ 저장
+ 홈 다시 정렬
+ 즐겨찾기한 앨범 동기화
+ 즐겨찾기 최신순
+ 즐겨찾기 오래된순
+ 트랜스코딩
+ 길게 눌러 삭제하기
+ 활성화 시, 별점이 음악 페이지에서 숨겨집니다 \n*다시 시작이 필요합니다
+ 즐겨찾기한 앨범을 다운로드할 시 많은 양의 데이터가 필요할 수 있습니다.
+ 즐겨찾기 한 앨범 동기화
+ 평점 표시하기
diff --git a/app/src/main/res/values-night/colors_widget.xml b/app/src/main/res/values-night/colors_widget.xml
new file mode 100644
index 00000000..7bda2da7
--- /dev/null
+++ b/app/src/main/res/values-night/colors_widget.xml
@@ -0,0 +1,7 @@
+
+
+ #CC000000
+ #FFFFFFFF
+ #B3FFFFFF
+ #FFFFFFFF
+
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index ddaca1b2..5c7e8b6c 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -3,6 +3,7 @@
Wyłącz optymalizacje baterii aby odtwarzać media przy wyłączonym ekranie.Optymalizcje BateriiTryb offline
+ Dodaj do playlistyDodaj do kolejkiPobierz wszystkiePrzejdź do wykonawcy
@@ -87,8 +88,12 @@
Wymaganewymagany jest prefiks http lub httpsPobieranie
+ Wyłącz serce
+ Włącz serce
+ Ładowanie…Wybierz dwa lub więcej filtrówFiltry
+ Filtruj wykonawcówFiltruj Gatunki(%1$d)(+%1$d)
@@ -103,13 +108,10 @@
ResetZapiszZmień układ strony głównej
- Weź pod uwagę to że, żeby zmiany nastąpiły, musisz zrestartować aplikację.
+ Weź pod uwagę to że, żeby zmiany nastąpiły, musisz zrestartować aplikację.MuzykaPodcastyRadio
- Głębia bitowa
- Częstotliwość próbkowania
- Język systemuTop piosenki od twoich ulubionych wykonawcówStwórz miks z piosenki którą lubiszDodaj nowe radio
@@ -118,6 +120,10 @@
PobierzPobieranie tych utworów może zużyć dużo danychWygląda na to że, są utwory oznaczone gwiazdką
+ Synchronizacja albumów oznaczonych gwiazdką
+ Albumy oznaczone gwiazdką będą dostępne offline
+ Synchronizacja wykonawców oznaczonych gwiazdką
+ Masz wykonawców oznaczonych gwiazdką, bez pobranej muzykiNajlepszeOdkrywanieOdtwórz wszystkie losowo
@@ -164,7 +170,9 @@
Serwery SubsonicPrzesyłanieDodaj
+ Dodaj do playlistyPobierz wszystko
+ Oceń albumPobraneWszystkoPobrane
@@ -193,13 +201,24 @@
Rok%1$.2fxWyczyść kolejkę odtwarzania
+ Zapisana kolejka odtwarzania
+ Pobierz teksty do odtwarzania offline
+ Teksty pobrane do odtwarzania offline
+ Zapisano tekst do odtwarzania offline.
+ Tekst nie jest dostępny do pobrania.Priorytet Serwerów
+ Nieznany format
+ Transkodowanie
+ zażądaneKatalog PlaylistPrzeglądaj PlaylistyNie utworzono playlistAnulujUtwórzDodaj do playlisty
+ Dodano piosenki do playlisty
+ Nie udało się dodać piosenek do playlisty
+ Pominięto wszystkie piosenki jako duplikaty%1$d utworów • %2$sDługość • %1$sPrzytrzymaj aby usunąć
@@ -266,6 +285,8 @@
Tempo jest otwarto-źródłowym i lekkim klientem muzycznym dla Subsonic, stworzonym i zbudowanym natywnie dla Androida.O aplikacjiAlways on display
+ Zezwalaj na dodawania duplikatów do playlist
+ Jeżeli włączone, duplikaty nie będą sprawdzane podczas dodawania do playlisty.Format transkodowaniaJeżeli włączone, Tempo nie będzię wymuszał pobierania utworu z ustawieniami transkodowania wybranymi poniżej.Priorytetyzuj ustawienia serwera używanego do strumieniowania w pobieraniach
@@ -281,6 +302,8 @@
Priorytet przy transkodowaniu utworu danego serwerowiStrategia buforowaniaAby zmiany przyniosły efekt, musisz ręcznie zrestartować aplikację.
+ Wybierz folder dla pobranych plików muzycznych
+ Wyczyść folder pobieraniaPozwala muzyce odtwarzać się dalej po końcu playlisty, odtwarza podobne piosenkiOdtwarzanie bez przerwyRozmiar cache dla okładek
@@ -289,11 +312,18 @@
Zatwierdzenie nieodwracalnie usunie wszystkie zapisane elementyUsuń zapisane elementyPamięć do pobierania
- Zmień ustawienia audio
- Equalizer
+ Utworzono folder pobierania.
+ Wybrano folder pobierania
+ Ustaw folder pobierania
+ Zmień ustawienia audio
+ Korektor systemowyhttps://github.com/eddyizm/tempoŚledź tworzenie aplikacjiGitHub
+ https://github.com/eddyizm/tempo/discussions
+ Dołącz do dyskusji i wsparcia społeczności
+ Wsparcie użytkowników
+ Skanowanie: naliczono %1$d utworówRozdzielczość obrazówJęzykWyloguj
@@ -307,13 +337,19 @@
Jeżeli włączone, widoczna będzie sekcja z podcastami. Zrestartuj aplikację aby, zmiany przyniosły pełny efekt.Pokaż jakość audioBitrate i format audio będzie pokazywany dla każdego utworu.
+ Pokaż ocenę piosenek w gwiazdkach
+ Jeżeli włączone, pokazuje ocenę w 5 gwiazdkach dla utworu na stronie piosenki\n\n*Wymaga ponownego uruchomienia aplikacjiPokaż oceny elementówJeżeli włączone, ocena elementów oraz czy jest oznaczony jako ulubiony będą pokazywane.Timer synchronizacjiJeżeli włączone, użytkownik będzie miał możliwość zapisania kolejki i będzie miał możliwość załadowania jej stanu przy otwarciu aplikacji.
- Synchronizuj kolejkę odtwarzania dla tego użytkownika
+ Synchronizuj kolejkę odtwarzania dla tego użytkownika [Niedokończone]
+ Pokaż przycisk odtwarzania losowego
+ Jeżeli włączone, pokazuje przycisk losowego odtwarzania, i usuwa przycisk serca w mini odtwarzaczuPokaż radioJeżeli włączone, widoczna będzie sekcja radia. Zrestartuj aplikację aby, zmiany przyniosły pełny efekt.
+ Automatyczne pobieranie tesktów
+ Automatycznie zapisuj teksty jeżeli, są dostępne aby, mogły być wyświetlane offline.Tryb wzmocnienia głośności przy ponownym odtwarzaniuZaokrąglone rogiRozmiar rogów
@@ -321,6 +357,7 @@
Jeżeli włączone, ustawia kąt krzywizny dla wszystkich renderowanych okładek. Zmiany przyniosą efekt po restarcie.Skanuj bibliotekęWłącz scrobbling muzyki
+ Język systemowyWłącz udostępnianie muzykiRozmiar cache dla strumieniowaniaPamięć cache dla strumieniowania
@@ -329,16 +366,21 @@
Wzmocnienie głośności jest funkcją która pozwala tobie na ustawienia poziomu głośności dla utworów aby słuchanie brzmiało cały czas tak samo. To ustawienia działa tylko wtedy kiedy utwór zawiera potrzebne metadane.Scrobbling jest funkcją która pozwala twojemu urządzeniu na wysyłanie informacji na temat piosenek których słuchasz do serwera muzyki. Te informacje pomagają tworzyć spersonalizowane rekomendacje na podstawie twojego gustu muzycznego.Pozwala udostępnić użytkownikowi muzykę przez link. Ta funkcjonalność musi być wspierana i włączona na serwerze i jest ograniczona do pojedyńczych utworów, albumów i playlist.
- Przywraca stan kolejki odtwarzania dla tego użytkownika. Zawiera utwory w kolejce, aktualnie odtwarzany utwór i pozycję w nim. Serwer musi wspierać tę funkcję.
+ Przywraca stan kolejki odtwarzania dla tego użytkownika. Zawiera utwory w kolejce, aktualnie odtwarzany utwór i pozycję w nim. Serwer musi wspierać tę funkcję.\n*To ustawienie nie działa na 100% na wszystkich serwerach/urządzeniach.%1$s \nAktualnie w użyciu: %2$s MiBPriorytet dawany trybowi transkodowania. Jeżeli ustawiony na \"Odtwarzanie bezpośrednie\" bitrate pliku nie zostanie zmieniony.Pobieraj transkdowane media. Jeżeli włączone, endpoint pobierania nie będzie używnany, poza następującymi ustawieniami. \n\n Jeżeli \"Format transkodowania dla pobierania\" jest ustawiony na \"Pobieranie bezpośrednie\" bitrate pliku nie zostanie zmieniony.Kiedy plik jest transkodowany w locie, klient nie pokazuje zwykle długości utworu.Jest możliwe odpytanie serwera który wspiera tą funkcjonalność aby oszacował długość odtwarzanego utworu, ale czasy odpowiedzi mogą być dłuższe.
+ Jeżeli włączone, utwory wykonawców oznaczonych gwiazdką będą pobierane do użycia offline.
+ Synchronizuj wykonawców oznacznych gwiazdką do użycia offline
+ Jeżeli włączone, albumy oznaczone gwiazdką będą pobieranew do użycia offline.
+ Synchronizuj albumy oznaczone gwiazdką do użycia offlineJeżeli włączone, utwory oznaczone gwiazdką będą pobrane do użycia offline.Zsynchronizuj utwory oznaczone gwiazdką do użycia offlineMotywDaneOgólne
+ PlaylistyOcenyWzmocnienie głośności przy ponownym odtwarzaniuScrobble
@@ -389,14 +431,19 @@
Kontynuuj i pobierzPobieranie utworów oznaczonych gwiazdką może wymagać dużej ilośći danych.Synchronizuj utwory oznaczone gwiazdką
+ Pobieranie utworów artystów oznaczonych gwiazdką może wymagać dużej ilośći danych.
+ Synchronizacja wykonawców oznaczonych gwiazdką
+ Pobieranie albumów oznaczonych gwiazdką może wymagać dużej ilośći danych.
+ Synchronizacja albumów oznaczonych gwiazdkąAby zmiany przyniosły efekt, zrestartuj aplikację.Zmiana lokalizacji plików cache z jednej na drugą spowoduje natychmiastowe usunięcie wcześniej pobranych plików cache w drugiej lokalizacji.Wybieranie pamięciZewnętrznaWewnętrzna
- https://buymeacoffee.com/a.cappiello
+ https://ko-fi.com/eddyizmAlbumWykonawca
+ Głębia bitowaBitrateTyp TreściOK
@@ -405,6 +452,7 @@
DługośćGatunekŚcieżka
+ Częstotliwość próbkowaniaRozmiarSufiksPlik został pobrany przy użyciu API Subsonic. Kodek i bitrate pliku pozostaje nie zmieniony względem pliku źródłowego.
@@ -421,4 +469,33 @@
unDrawSpecjalne podziękowania dla unDraw bez którego ilustracji nie mogliśmy uczynić tej aplikacji jeszcze piękniejszą.https://undraw.co/
+ Widget Tempo
+ Nie odtwarza
+ Otwórz Tempo
+ 0:00
+ 0:00
+ Okładka albumu
+ Play lub pauza
+ Następny utwór
+ Poprzedni utwór
+ Przełącznik odtwarzania losowego
+ Zmień tryb powtarzania
+
+ %d album do zsynchronizowania
+ %d albumów do zsynchrpnizowania
+
+
+ %d wykonawca do zsynchronizowania
+ %d wykonawców do zsynchronizowania
+
+
+ Pobieranie %d piosenki
+ Pobieranie %d piosenek
+
+ Korektor dźwięku
+ Reset
+ Włączony
+ Nie wspierane na tym urządzeniu
+ Korektor dźwięku
+ Otwórz wbudowany korektor dźwięku
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 7e4f2de7..294e5893 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -248,8 +248,8 @@
O processo resultará na exclusão irreversível de todos os itens salvos.Excluir itens salvosArmazenamento dos downloads
- Ajustar configurações de áudio
- Equalizador
+ Ajustar configurações de áudio
+ Equalizador do sistemahttps://github.com/eddyizm/tempoAcompanhe o desenvolvimentoGithub
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 43448d65..20c16ba3 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -2,7 +2,7 @@
Если у вас возникли проблемы, посетите https://dontkillmyapp.com. Он содержит подробные инструкции о том, как отключить любые функции энергосбережения, которые могут повлиять на производительность приложения.Пожалуйста, отключите оптимизацию батареи для воспроизведения мультимедиа при выключенном экране.Оптимизация батареи
- Офлайн-режим
+ Автономный режимДобавить в плейлистДобавить в очередьСкачать все
@@ -90,6 +90,7 @@
ЗагрузкиВыберите два или более фильтровФильтр
+ Фильтровать исполнителейФильтровать жанрыКаталог жанровПросмотр жанров
@@ -103,6 +104,9 @@
СохранятьНастроить главнуюОбратите внимание, чтобы внесенные изменения вступили в силу, необходимо перезапустить приложение.
+ Музыка
+ Подкасты
+ РадиоЛучшие треки любимых исполнителейЗапустите микс с понравившимся вам трекомДобавить новое радио
@@ -111,6 +115,8 @@
СкачатьЗагрузка этих треков может потребовать значительного использования данныхПохоже, есть несколько отмеченных треков для синхронизации.
+ Синхронизировать отмеченные альбомы
+ Отмеченные альбомы будут доступны в автономном режимеЛучшее изОткрытиеПеремешать все
@@ -126,6 +132,7 @@
Увидеть всеНовые релизыНовейшие подкасты
+ ПлейлистыКаналыУвидеть всеРадиостанции
@@ -156,6 +163,7 @@
ДобавитьДобавить в плейлистСкачать все
+ Оценить альбомСкачатьВсеЗагружено
@@ -165,6 +173,9 @@
ТрекГодГлавная
+ Прошлая неделя
+ Прошлый месяц
+ Прошлый годБиблиотекаПоискНастройки
@@ -181,7 +192,11 @@
Убрать с главного экрана%1$.2fxОчистить очередь воспроизведения
+ Очередь сохраненаПриоритет сервера
+ Неизвестный форма
+ Транскодирование
+ запрошеноКаталог плейлистовПросмотр плейлистовПлейлисты не созданы
@@ -271,14 +286,16 @@
Приоритет при перекодировании трека отдается серверуСтратегия буферизацииЧтобы изменения вступили в силу, необходимо вручную перезапустить приложение.
+ Разрешить играть включать треки после окончания плейлиста
+ Продолжать игратьРазмер кэша обложекЧтобы сократить потребление данных, избегайте загрузки обложек.Ограничить использование мобильных данныхПродолжение приведет к необратимому удалению всех сохраненных элементов.Удалить сохраненные элементыЗагрузить хранилище
- Отрегулируйте настройки звука
- Эквалайзер
+ Отрегулируйте настройки звука
+ Системный эквалайзерhttps://github.com/eddyizm/tempoСледите за развитиемGithub
@@ -295,6 +312,8 @@
Если включено, показывать раздел подкаста. Перезапустите приложение, чтобы оно вступило в силу.Показать качество звука (битрейт)Битрейт и аудиоформат будут показаны для каждой аудиодорожки.
+ Показать рейтинг трека
+ Если эта функция включена, будет отображаться пятизвездочный рейтинг трека на странице воспроизведения\n\n*Требует перезапуска приложенияПоказать рейтингЕсли эта функция включена, будет отображаться рейтинг элемента и то, отмечен ли он как избранный.Таймер синхронизации
@@ -309,18 +328,24 @@
Если этот параметр включен, задает угол кривизны для всех отображаемых обложек. Изменения вступят в силу при перезапуске.Сканировать библиотекуВключить скробблинг музыки Last.FM и т.д.
+ Язык системыВключить обмен музыкой
+ Размер кэша стриминга
+ Хранилище кэша стримингаВажно отметить, что скробблинг также зависит от того, настроен ли сервер для получения этих данных.При прослушивании радио исполнителя, мгновенном миксе или перемешивании всех, треки ниже определенного пользовательского рейтинга будут игнорироваться.Усиление воспроизведения — это функция, которая позволяет регулировать уровень громкости звуковых дорожек для обеспечения единообразного качества прослушивания. Этот параметр действует только в том случае, если трек содержит необходимые метаданные.Скробблинг — это функция, которая позволяет вашему устройству отправлять информацию о песнях, которые вы слушаете, на музыкальный сервер. Эта информация помогает создавать персональные рекомендации на основе ваших музыкальных предпочтений.Позволяет пользователю делиться музыкой по ссылке. Функциональность должна поддерживаться и включаться на стороне сервера и ограничивается отдельными треками, альбомами и плейлистами.Возвращает состояние очереди воспроизведения для этого пользователя. Сюда входят треки в очереди воспроизведения, воспроизводимый в данный момент трек и позиция внутри этого трека. Сервер должен поддерживать эту функцию.
+ %1$s \nСейчас используется: %2$s MiBПриоритет отдается режиму перекодирования. Если установлено «Прямое воспроизведение», битрейт файла не изменится.Загрузите перекодированные медиафайлы. Если этот параметр включен, будет использоваться не конечная точка загрузки, а следующие настройки. Если для параметра «Формат перекодирования для загрузки» установлено значение «Прямая загрузка», битрейт файла не изменится.Когда файл перекодируется на лету, клиент обычно не показывает длину трека. Можно запросить у серверов, поддерживающих данную функцию, оценку длительности воспроизводимого трека, но время ответа может занять больше времени.
+ Если этот параметр включен, помеченные альбомы будут загружены для использования в автономном режиме.
+ Синхронизировать помеченные альбомы для использования в автономном режиме.Если этот параметр включен, помеченные треки будут загружены для использования в автономном режиме.
- Синхронизируйте помеченные треки для использования в автономном режиме.
+ Синхронизировать помеченные треки для использования в автономном режиме.ТемаДанныеОбщий
@@ -332,7 +357,7 @@
ПоделитьсяСинхронизацииТранскодирование
- Транскодирование Скачать
+ Скачивание с транскодированиемUI (Пользовательский интерфейс)Перекодированная загрузкаВерсия
@@ -373,8 +398,16 @@
Продолжить и скачатьДля скачивания рейтинговых треков может потребоваться большой объем данных.Синхронизировать отмеченные треки
+ Для скачивания рейтинговых альбомов может потребоваться большой объем данных.
+ Синхронизировать отмеченные альбомы
+ Чтобы изменения вступили в силу необходимо перезапустить приложение.
+ Изменение места сохранения кэшированных файлов с одного на другое может привести к удалению файлов в старом хранилище.
+ Выберите способ сохранения
+ Внешний
+ ВнутреннийАльбомИсполнитель
+ РазрядностьБитрейтТип содержимогоOK
@@ -383,6 +416,7 @@
ПродолжительностьЖанрПуть
+ Частота сэмплированияРазмерСуффиксФайл был загружен с использованием API Subsonic. Кодек и битрейт файла остаются неизменными по сравнению с исходным файлом.
@@ -399,4 +433,8 @@
РазвернутьОсобая благодарность — команде unDraw, без иллюстраций которой мы не смогли бы сделать это приложение красивее.https://undraw.co/
+
+ Альбомов для синхронизации: %d
+ Альбомов для синхронизации: %d
+
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index fa915fec..fc2bfe00 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -90,6 +90,7 @@
İndirilenlerİki veya daha fazla filtre seçinFiltre
+ Sanatçıları filtreleTürleri filtrele(%1$d)(+%1$d)
@@ -116,6 +117,7 @@
İndirBu parçaların indirilmesi önemli miktarda veri kullanabilirEşitlenecek bazı yıldızlı parçalar var gibi görünüyor
+ Yıldız ile işaretlenen albümler çevrimdışı kullanılabilir olacakEn iyilerKeşfetTümünü karıştır
@@ -164,6 +166,7 @@
EkleÇalma listesine ekleTümünü indir
+ Albümü oylaİndirTümüİndirilenler
@@ -192,6 +195,7 @@
Yıl%1$.2fxÇalma sırasını temizle
+ Kayıtlı oynatma sırasıSunucu önceliğiBilinmeyen formatDönüştürme
@@ -293,8 +297,8 @@
Devam ederseniz tüm kayıtlı öğeler geri alınamaz şekilde silinecektir.Kayıtlı öğeleri silİndirme depolaması
- Ses ayarlarını düzenle
- Ekolayzır
+ Ses ayarlarını düzenle
+ Sistem ekolayzırhttps://github.com/eddyizm/tempoGelişmeleri takip etGithub
@@ -311,6 +315,7 @@
Etkinleştirildiğinde podcast bölümü görüntülenir. Tam etkili olması için uygulamayı yeniden başlatın.Ses kalitesini gösterHer ses parçası için bit hızı ve ses formatı gösterilecektir.
+ " "Öğe değerlemesini gösterEtkinleştirildiğinde, öğenin puanı ve favori olarak işaretlenip işaretlenmediği görüntülenir.Eşitleme zamanlayıcısı
@@ -340,6 +345,7 @@
Dönüştürülmüş medyayı indir. Etkinleştirilirse indirme uç noktası kullanılmaz, bunun yerine aşağıdaki ayarlar geçerli olur. \n\n “İndirmeler için dönüştürme formatı” “Doğrudan indir” olarak ayarlanırsa dosyanın bit hızı değiştirilmez.Dosya anlık olarak dönüştürüldüğünde, istemci genellikle parçanın süresini göstermez. Bu işlevi destekleyen sunuculardan çalınan parçanın süresini tahmin etmeleri istenebilir,
ancak yanıt süreleri daha uzun olabilir.
+ Çevrimdışı kullanım için yıldızlı albümleri senkronize etEtkinleştirildiğinde, yıldızlı parçalar çevrimdışı kullanım için indirilecektir.Çevrimdışı kullanım için yıldızlı parçaları eşitleTema
@@ -395,6 +401,8 @@
Devam et ve indirYıldızlı parçaların indirilmesi yüksek miktarda veri gerektirebilir.Yıldızlı parçaları eşitle
+ Yıldızlı albümleri indirmek yüksek miktarda veri kullanımı gerektirebilir.
+ Yıldızlı albümleri senkronize etDeğişikliklerin geçerli olması için uygulamayı yeniden başlatın.Önbelleğe alınmış dosyaların hedefini bir depolamadan diğerine değiştirmek, önceki depolamadaki önbellek dosyalarının silinmesine yol açabilir.Depolama seçeneğini seç
@@ -433,4 +441,16 @@
unDrawİllüstrasyonlarıyla bu uygulamayı daha güzel hale getirmemize yardımcı olan unDraw’a özel teşekkürler.https://undraw.co/
+ Yıldızlı Albümleri Senkronize Et
+ Tempo Widget
+ Şu an oynatılmıyor
+ Tempo’yu aç
+ 0:00
+ 0:00
+ Albüm kapağı
+ Çal/Duraklat
+ Sonraki parça
+ Önceki parça
+ Şarkının yıldız derecelendirmesini göster
+ "Etkinleştirildiğinde yıldızlı albümler çevrimdışı kullanım için indirilecek. "
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index b0933d57..c2808c8b 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -255,8 +255,8 @@
继续当前操作将导致所有已保存的项目被永久删除。删除已保存的项目下载存储
- 调整音频设置
- 均衡器
+ 调整音频设置
+ 系统均衡器https://github.com/eddyizm/tempo关注开发进展Github
diff --git a/app/src/main/res/values/colors_widget.xml b/app/src/main/res/values/colors_widget.xml
new file mode 100644
index 00000000..71a34138
--- /dev/null
+++ b/app/src/main/res/values/colors_widget.xml
@@ -0,0 +1,9 @@
+
+
+
+ #CCFFFFFF
+ #DE000000
+ #99000000
+ #DE000000
+ #FF6750A4
+
diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml
new file mode 100644
index 00000000..c29aa81f
--- /dev/null
+++ b/app/src/main/res/values/ids.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml
new file mode 100644
index 00000000..e1a1ac1c
--- /dev/null
+++ b/app/src/main/res/values/integers.xml
@@ -0,0 +1,6 @@
+
+
+ 100
+ 160
+ 220
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 619766d5..01f53610 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -68,6 +68,7 @@
DownloadAll tracks in this folder will be downloaded. Tracks present in subfolders will not be downloaded.Download the tracks
+ Set where music is downloadedOnce you download a song, you\'ll find it hereNo downloads yet!%1$s • %2$s items
@@ -78,7 +79,15 @@
Select storage optionExternalInternal
+ DirectoryDownloads
+ Set a download folder to refresh your downloads.
+ No missing downloads found.
+
+ Removed %d missing download.
+ Removed %d missing downloads.
+
+ Refresh downloaded itemsAdd to queuePlay nextRemove
@@ -88,6 +97,9 @@
Requiredhttp or https prefix requiredDownloads
+ Toggle Heart off
+ Toggle Heart on
+ Loading…Select two or more filtersFilterFilter artists
@@ -119,6 +131,8 @@
Looks like there are some starred tracks to syncSync Starred AlbumsAlbums marked with a star will be available offline
+ Starred Artists Sync
+ You have starred artists with music not downloadedBest ofDiscoveryShuffle all
@@ -197,6 +211,10 @@
%1$.2fxClean play queueSaved play queue
+ Download lyrics for offline playback
+ Lyrics downloaded for offline playback
+ Lyrics saved for offline playback.
+ Lyrics are not available to download.Server PriorityUnknown formatTranscoding
@@ -207,8 +225,9 @@
CancelCreateAdd to a playlist
- Added song to playlist
- Failed to add song to playlist
+ Added song(s) to playlist
+ Failed to add song(s) to playlist
+ All songs were skipped as duplicates%1$d tracks • %2$sDuration • %1$sLong press to delete
@@ -275,6 +294,8 @@
Tempo is an open source and lightweight music client for Subsonic, designed and built natively for Android.AboutAlways on display
+ Allow adding duplicates to playlist
+ If enabled, duplicates won\'t be checked while adding to a playlist.Transcode formatIf enabled, Tempo will not force download the track with the transcode settings below.Prioritize server settings used for streaming in downloads
@@ -290,6 +311,8 @@
Priority on transcoding of track given to serverBuffering strategyFor the change to take effect you must manually restart the app.
+ Choose a folder for downloaded music files
+ Clear download folderAllows music to keep playing after a playlist has ended, playing similar songsContinuous playSize of artwork cache
@@ -298,11 +321,18 @@
Proceeding will result in the irreversible deletion of all saved items.Delete saved itemsDownload storage
- Adjust audio settings
- Equalizer
+ Download folder cleared.
+ Download folder set
+ Set download folder
+ Adjust audio settings
+ System equalizerhttps://github.com/eddyizm/tempoFollow the developmentGithub
+ https://github.com/eddyizm/tempo/discussions
+ Join community discussions and support
+ User support
+ Scanning: counting %1$d tracksSet image resolutionLanguageLog out
@@ -323,8 +353,12 @@
Sync timerIf enabled, the user will have the ability to save their play queue and will have the ability to load state when opening the application.Sync play queue for this user [Not Fully Baked]
+ Show Shuffle button
+ If enabled, show the shuffle button, remove the heart in the mini playerShow radioIf enabled, show the radio section. Restart the app for it to take full effect.
+ Auto download lyrics
+ Automatically save lyrics when they are available so they can be shown while offline.Set replay gain modeRounded cornersCorners size
@@ -346,6 +380,8 @@
Priority given to the transcoding mode. If set to \"Direct play\" the bitrate of the file will not be changed.Download transcoded media. If enabled, the download endpoint will not be used, but the following settings. \n\n If \"Transcode format for donwloads\" is set to \"Direct download\" the bitrate of the file will not be changed.When the file is transcoded on the fly, the client usually does not show the track length. It is possible to request the servers that support the functionality to estimate the duration of the track being played, but the response times may take longer.
+ If enabled, starred artists will be downloaded for offline use.
+ Sync starred artists for offline useIf enabled, starred albums will be downloaded for offline use.Sync starred albums for offline useIf enabled, starred tracks will be downloaded for offline use.
@@ -353,6 +389,7 @@
ThemeDataGeneral
+ PlaylistRatingReplay GainScrobble
@@ -373,6 +410,22 @@
Update shareExpiration date: %1$sSharing is not supported or not enabled
+ Tempo asset link
+ Song UID
+ Album UID
+ Artist UID
+ Playlist UID
+ Genre UID
+ Year UID
+ Asset UID
+ Unsupported asset link
+ Song could not be opened
+ Album could not be opened
+ Artist could not be opened
+ Playlist could not be opened
+ %1$s • %2$s
+ Copied %1$s to clipboard
+ Asset link: %1$sDescriptionExpiration dateCancel
@@ -403,6 +456,8 @@
Continue and downloadDownloading starred tracks may require a large amount of data.Sync starred tracks
+ Downloading starred artists may require a large amount of data.
+ Sync starred artistsDownloading starred albums may require a large amount of data.Sync starred albumsFor the changes to take effect, restart the app.
@@ -410,7 +465,7 @@
Select storage optionExternalInternal
- https://buymeacoffee.com/a.cappiello
+ https://ko-fi.com/eddyizmAlbumArtistBit depth
@@ -439,8 +494,33 @@
unDrawA special thanks goes to unDraw without whose illustrations we could not have made this application more beautiful.https://undraw.co/
+ Tempo Widget
+ Not playing
+ Open Tempo
+ 0:00
+ 0:00
+ Album artwork
+ Play or pause
+ Next track
+ Previous track
+ Toggle shuffle
+ Change repeat mode%d album to sync%d albums to sync
+
+ %d artist to sync
+ %d artists to sync
+
+
+ Downloading %d song
+ Downloading %d songs
+
+ Equalizer
+ Reset
+ Enable
+ Not supported on this device
+ Equalizer
+ Open the built-in equalizer
diff --git a/app/src/main/res/xml/global_preferences.xml b/app/src/main/res/xml/global_preferences.xml
index 09afcb1d..ad15349d 100644
--- a/app/src/main/res/xml/global_preferences.xml
+++ b/app/src/main/res/xml/global_preferences.xml
@@ -2,9 +2,16 @@
xmlns:app="http://schemas.android.com/apk/res-auto">
+ android:layout_height="match_parent"
+ android:key="system_equalizer"
+ android:summary="@string/settings_system_equalizer_summary"
+ android:title="@string/settings_system_equalizer_title" />
+
+
+ app:title="@string/settings_language" />
+ android:title="@string/settings_rounded_corner" />
+ android:title="@string/settings_audio_quality" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/widget_info.xml b/app/src/main/res/xml/widget_info.xml
new file mode 100644
index 00000000..03e072ce
--- /dev/null
+++ b/app/src/main/res/xml/widget_info.xml
@@ -0,0 +1,10 @@
+
+
diff --git a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt
index 92c4b9b3..c669a7d3 100644
--- a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt
+++ b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt
@@ -5,22 +5,31 @@ import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.TaskStackBuilder
import android.content.Intent
+import android.os.Binder
import android.os.Bundle
+import android.os.IBinder
+import android.os.Handler
+import android.os.Looper
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.DefaultMediaSourceFactory
+import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.TrackGroupArray
import androidx.media3.exoplayer.trackselection.TrackSelectionArray
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.AssetLinkUtil
import com.cappielloantonio.tempo.util.Constants
import com.cappielloantonio.tempo.util.DownloadUtil
+import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
+import com.cappielloantonio.tempo.util.MappingUtil
import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil
+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
@@ -34,8 +43,29 @@ class MediaService : MediaLibraryService() {
private lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var shuffleCommands: List
private lateinit var repeatCommands: List
+ lateinit var equalizerManager: EqualizerManager
private var customLayout = ImmutableList.of()
+ private val widgetUpdateHandler = Handler(Looper.getMainLooper())
+ private var widgetUpdateScheduled = false
+ private val widgetUpdateRunnable = object : Runnable {
+ override fun run() {
+ if (!player.isPlaying) {
+ widgetUpdateScheduled = false
+ return
+ }
+ updateWidget()
+ widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
+ }
+ }
+
+ inner class LocalBinder : Binder() {
+ fun getEqualizerManager(): EqualizerManager {
+ return this@MediaService.equalizerManager
+ }
+ }
+
+ private val binder = LocalBinder()
companion object {
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
@@ -48,6 +78,7 @@ class MediaService : MediaLibraryService() {
"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"
}
override fun onCreate() {
@@ -56,7 +87,9 @@ class MediaService : MediaLibraryService() {
initializeCustomCommands()
initializePlayer()
initializeMediaLibrarySession()
+ restorePlayerFromQueue()
initializePlayerListener()
+ initializeEqualizerManager()
setPlayer(player)
}
@@ -66,10 +99,21 @@ class MediaService : MediaLibraryService() {
}
override fun onDestroy() {
+ equalizerManager.release()
+ stopWidgetUpdates()
releasePlayer()
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 inner class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback {
override fun onConnect(
@@ -79,15 +123,17 @@ class MediaService : MediaLibraryService() {
val connectionResult = super.onConnect(session, controller)
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
- shuffleCommands.forEach { commandButton ->
- // TODO: Aggiungere i comandi personalizzati
- // commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
+ (shuffleCommands + repeatCommands).forEach { commandButton ->
+ commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
}
- return MediaSession.ConnectionResult.accept(
- availableSessionCommands.build(),
- connectionResult.availablePlayerCommands
- )
+ customLayout = buildCustomLayout(session.player)
+
+ return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
+ .setAvailableSessionCommands(availableSessionCommands.build())
+ .setAvailablePlayerCommands(connectionResult.availablePlayerCommands)
+ .setCustomLayout(customLayout)
+ .build()
}
override fun onPostConnect(session: MediaSession, controller: ControllerInfo) {
@@ -197,6 +243,21 @@ class MediaService : MediaLibraryService() {
player.repeatMode = Preferences.getRepeatMode()
}
+ private fun initializeEqualizerManager() {
+ equalizerManager = EqualizerManager()
+ val audioSessionId = player.audioSessionId
+ if (equalizerManager.attachToSession(audioSessionId)) {
+ 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])
+ }
+ }
+ }
+
private fun initializeMediaLibrarySession() {
val sessionActivityPendingIntent =
TaskStackBuilder.create(this).run {
@@ -214,6 +275,33 @@ class MediaService : MediaLibraryService() {
}
}
+ private fun restorePlayerFromQueue() {
+ 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()
+ }
+
private fun initializePlayerListener() {
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
@@ -222,11 +310,15 @@ class MediaService : MediaLibraryService() {
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem)
}
+ updateWidget()
}
override fun onTracksChanged(tracks: Tracks) {
ReplayGainUtil.setReplayGain(player, tracks)
- MediaManager.scrobble(player.currentMediaItem, false)
+ val currentMediaItem = player.currentMediaItem
+ if (currentMediaItem != null && currentMediaItem.mediaMetadata.extras != null) {
+ MediaManager.scrobble(currentMediaItem, false)
+ }
if (player.currentMediaItemIndex + 1 == player.mediaItemCount)
MediaManager.continuousPlay(player.currentMediaItem)
@@ -241,6 +333,12 @@ class MediaService : MediaLibraryService() {
} else {
MediaManager.scrobble(player.currentMediaItem, false)
}
+ if (isPlaying) {
+ scheduleWidgetUpdates()
+ } else {
+ stopWidgetUpdates()
+ }
+ updateWidget()
}
override fun onPlaybackStateChanged(playbackState: Int) {
@@ -252,6 +350,7 @@ class MediaService : MediaLibraryService() {
MediaManager.scrobble(player.currentMediaItem, true)
MediaManager.saveChronology(player.currentMediaItem)
}
+ updateWidget()
}
override fun onPositionDiscontinuity(
@@ -285,6 +384,9 @@ class MediaService : MediaLibraryService() {
mediaLibrarySession.setCustomLayout(customLayout)
}
})
+ if (player.isPlaying) {
+ scheduleWidgetUpdates()
+ }
}
private fun setPlayer(player: Player) {
@@ -330,7 +432,7 @@ class MediaService : MediaLibraryService() {
.build()
}
- private fun ignoreFuture(customLayout: ListenableFuture) {
+ private fun ignoreFuture(@Suppress("UNUSED_PARAMETER") customLayout: ListenableFuture) {
/* Do nothing. */
}
@@ -345,8 +447,57 @@ class MediaService : MediaLibraryService() {
.build()
}
+ private fun updateWidget() {
+ 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 getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
- private fun getMediaSourceFactory() =
- DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this))
-}
\ No newline at end of file
+ private fun getMediaSourceFactory(): MediaSource.Factory = DynamicMediaSourceFactory(this)
+}
+
+private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
diff --git a/app/src/play/java/com/cappielloantonio/tempo/service/MediaBrowserTree.kt b/app/src/play/java/com/cappielloantonio/tempo/service/MediaBrowserTree.kt
deleted file mode 100644
index f88f9b6b..00000000
--- a/app/src/play/java/com/cappielloantonio/tempo/service/MediaBrowserTree.kt
+++ /dev/null
@@ -1,497 +0,0 @@
-package com.cappielloantonio.tempo.service
-
-import android.net.Uri
-import androidx.lifecycle.LifecycleOwner
-import androidx.media3.common.MediaItem
-import androidx.media3.common.MediaItem.SubtitleConfiguration
-import androidx.media3.common.MediaMetadata
-import androidx.media3.session.LibraryResult
-import com.cappielloantonio.tempo.repository.AutomotiveRepository
-import com.cappielloantonio.tempo.util.Preferences.getServerId
-import com.google.common.collect.ImmutableList
-import com.google.common.util.concurrent.Futures
-import com.google.common.util.concurrent.ListenableFuture
-import com.google.common.util.concurrent.SettableFuture
-
-object MediaBrowserTree {
-
- private lateinit var automotiveRepository: AutomotiveRepository
-
- private var treeNodes: MutableMap = mutableMapOf()
-
- private var isInitialized = false
-
- // Root
- private const val ROOT_ID = "[rootID]"
-
- // First level
- private const val HOME_ID = "[homeID]"
- private const val LIBRARY_ID = "[libraryID]"
- private const val OTHER_ID = "[otherID]"
-
- // Second level HOME_ID
- private const val MOST_PLAYED_ID = "[mostPlayedID]"
- private const val LAST_PLAYED_ID = "[lastPlayedID]"
- private const val RECENTLY_ADDED_ID = "[recentlyAddedID]"
- private const val RECENT_SONGS_ID = "[recentSongsID]"
- private const val MADE_FOR_YOU_ID = "[madeForYouID]"
- private const val STARRED_TRACKS_ID = "[starredTracksID]"
- private const val STARRED_ALBUMS_ID = "[starredAlbumsID]"
- private const val STARRED_ARTISTS_ID = "[starredArtistsID]"
- private const val RANDOM_ID = "[randomID]"
-
- // Second level LIBRARY_ID
- private const val FOLDER_ID = "[folderID]"
- private const val INDEX_ID = "[indexID]"
- private const val DIRECTORY_ID = "[directoryID]"
- private const val PLAYLIST_ID = "[playlistID]"
-
- // Second level OTHER_ID
- private const val PODCAST_ID = "[podcastID]"
- private const val RADIO_ID = "[radioID]"
-
- private const val ALBUM_ID = "[albumID]"
- private const val ARTIST_ID = "[artistID]"
-
- private class MediaItemNode(val item: MediaItem) {
- private val children: MutableList = ArrayList()
-
- fun addChild(childID: String) {
- this.children.add(treeNodes[childID]!!.item)
- }
-
- fun getChildren(): ListenableFuture>> {
- val listenableFuture = SettableFuture.create>>()
- val libraryResult = LibraryResult.ofItemList(children, null)
-
- listenableFuture.set(libraryResult)
-
- return listenableFuture
- }
- }
-
- private fun buildMediaItem(
- title: String,
- mediaId: String,
- isPlayable: Boolean,
- isBrowsable: Boolean,
- mediaType: @MediaMetadata.MediaType Int,
- subtitleConfigurations: List = mutableListOf(),
- album: String? = null,
- artist: String? = null,
- genre: String? = null,
- sourceUri: Uri? = null,
- imageUri: Uri? = null
- ): MediaItem {
- val metadata =
- MediaMetadata.Builder()
- .setAlbumTitle(album)
- .setTitle(title)
- .setArtist(artist)
- .setGenre(genre)
- .setIsBrowsable(isBrowsable)
- .setIsPlayable(isPlayable)
- .setArtworkUri(imageUri)
- .setMediaType(mediaType)
- .build()
-
- return MediaItem.Builder()
- .setMediaId(mediaId)
- .setSubtitleConfigurations(subtitleConfigurations)
- .setMediaMetadata(metadata)
- .setUri(sourceUri)
- .build()
- }
-
- fun initialize(automotiveRepository: AutomotiveRepository) {
- this.automotiveRepository = automotiveRepository
-
- if (isInitialized) return
-
- isInitialized = true
-
- // Root level
-
- treeNodes[ROOT_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Root Folder",
- mediaId = ROOT_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
- )
- )
-
- // First level
-
- treeNodes[HOME_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Home",
- mediaId = HOME_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
- )
- )
-
- treeNodes[LIBRARY_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Library",
- mediaId = LIBRARY_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
- )
- )
-
- treeNodes[OTHER_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Other",
- mediaId = OTHER_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
- )
- )
-
- treeNodes[ROOT_ID]!!.addChild(HOME_ID)
- treeNodes[ROOT_ID]!!.addChild(LIBRARY_ID)
- treeNodes[ROOT_ID]!!.addChild(OTHER_ID)
-
- // Second level HOME_ID
-
- treeNodes[MOST_PLAYED_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Most played",
- mediaId = MOST_PLAYED_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
- )
- )
-
- treeNodes[LAST_PLAYED_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Last played",
- mediaId = LAST_PLAYED_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
- )
- )
-
- treeNodes[RECENTLY_ADDED_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Recently added",
- mediaId = RECENTLY_ADDED_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
- )
- )
-
- treeNodes[RECENT_SONGS_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Recent songs",
- mediaId = RECENT_SONGS_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
- )
- )
-
- treeNodes[MADE_FOR_YOU_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Made for you",
- mediaId = MADE_FOR_YOU_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS
- )
- )
-
- treeNodes[STARRED_TRACKS_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Starred tracks",
- mediaId = STARRED_TRACKS_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
- )
- )
-
- treeNodes[STARRED_ALBUMS_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Starred albums",
- mediaId = STARRED_ALBUMS_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS
- )
- )
-
- treeNodes[STARRED_ARTISTS_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Starred artists",
- mediaId = STARRED_ARTISTS_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS
- )
- )
-
- treeNodes[RANDOM_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Random",
- mediaId = RANDOM_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
- )
- )
-
- treeNodes[HOME_ID]!!.addChild(MOST_PLAYED_ID)
- treeNodes[HOME_ID]!!.addChild(LAST_PLAYED_ID)
- treeNodes[HOME_ID]!!.addChild(RECENTLY_ADDED_ID)
- treeNodes[HOME_ID]!!.addChild(RECENT_SONGS_ID)
- treeNodes[HOME_ID]!!.addChild(MADE_FOR_YOU_ID)
- treeNodes[HOME_ID]!!.addChild(STARRED_TRACKS_ID)
- treeNodes[HOME_ID]!!.addChild(STARRED_ALBUMS_ID)
- treeNodes[HOME_ID]!!.addChild(STARRED_ARTISTS_ID)
- treeNodes[HOME_ID]!!.addChild(RANDOM_ID)
-
- // Second level LIBRARY_ID
-
- treeNodes[FOLDER_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Folders",
- mediaId = FOLDER_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_MIXED
- )
- )
-
- treeNodes[PLAYLIST_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Playlists",
- mediaId = PLAYLIST_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS
- )
- )
-
- treeNodes[LIBRARY_ID]!!.addChild(FOLDER_ID)
- treeNodes[LIBRARY_ID]!!.addChild(PLAYLIST_ID)
-
- // Second level OTHER_ID
-
- treeNodes[PODCAST_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Podcasts",
- mediaId = PODCAST_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_PODCASTS
- )
- )
-
- treeNodes[RADIO_ID] =
- MediaItemNode(
- buildMediaItem(
- title = "Radio stations",
- mediaId = RADIO_ID,
- isPlayable = false,
- isBrowsable = true,
- mediaType = MediaMetadata.MEDIA_TYPE_FOLDER_RADIO_STATIONS
- )
- )
-
- treeNodes[OTHER_ID]!!.addChild(PODCAST_ID)
- treeNodes[OTHER_ID]!!.addChild(RADIO_ID)
- }
-
- fun getRootItem(): MediaItem {
- return treeNodes[ROOT_ID]!!.item
- }
-
- fun getChildren(
- id: String
- ): ListenableFuture>> {
- return when (id) {
- ROOT_ID -> treeNodes[ROOT_ID]?.getChildren()!!
- HOME_ID -> treeNodes[HOME_ID]?.getChildren()!!
- LIBRARY_ID -> treeNodes[LIBRARY_ID]?.getChildren()!!
- OTHER_ID -> treeNodes[OTHER_ID]?.getChildren()!!
-
- MOST_PLAYED_ID -> automotiveRepository.getAlbums(id, "frequent", 100)
- LAST_PLAYED_ID -> automotiveRepository.getAlbums(id, "recent", 100)
- RECENTLY_ADDED_ID -> automotiveRepository.getAlbums(id, "newest", 100)
- RECENT_SONGS_ID -> automotiveRepository.getRecentlyPlayedSongs(getServerId(),100)
- MADE_FOR_YOU_ID -> automotiveRepository.getStarredArtists(id)
- STARRED_TRACKS_ID -> automotiveRepository.starredSongs
- STARRED_ALBUMS_ID -> automotiveRepository.getStarredAlbums(id)
- STARRED_ARTISTS_ID -> automotiveRepository.getStarredArtists(id)
- RANDOM_ID -> automotiveRepository.getRandomSongs(100)
- FOLDER_ID -> automotiveRepository.getMusicFolders(id)
- PLAYLIST_ID -> automotiveRepository.getPlaylists(id)
- PODCAST_ID -> automotiveRepository.getNewestPodcastEpisodes(100)
- RADIO_ID -> automotiveRepository.internetRadioStations
-
- else -> {
- if (id.startsWith(MOST_PLAYED_ID)) {
- return automotiveRepository.getAlbumTracks(
- id.removePrefix(
- MOST_PLAYED_ID
- )
- )
- }
-
- if (id.startsWith(LAST_PLAYED_ID)) {
- return automotiveRepository.getAlbumTracks(
- id.removePrefix(
- LAST_PLAYED_ID
- )
- )
- }
-
- if (id.startsWith(RECENTLY_ADDED_ID)) {
- return automotiveRepository.getAlbumTracks(
- id.removePrefix(
- RECENTLY_ADDED_ID
- )
- )
- }
-
- if (id.startsWith(MADE_FOR_YOU_ID)) {
- return automotiveRepository.getMadeForYou(
- id.removePrefix(
- MADE_FOR_YOU_ID
- ),
- 20
- )
- }
-
- if (id.startsWith(STARRED_ALBUMS_ID)) {
- return automotiveRepository.getAlbumTracks(
- id.removePrefix(
- STARRED_ALBUMS_ID
- )
- )
- }
-
- if (id.startsWith(STARRED_ARTISTS_ID)) {
- return automotiveRepository.getArtistAlbum(
- STARRED_ALBUMS_ID,
- id.removePrefix(
- STARRED_ARTISTS_ID
- )
- )
- }
-
- if (id.startsWith(FOLDER_ID)) {
- return automotiveRepository.getIndexes(
- INDEX_ID,
- id.removePrefix(
- FOLDER_ID
- )
- )
- }
-
- if (id.startsWith(INDEX_ID)) {
- return automotiveRepository.getDirectories(
- DIRECTORY_ID,
- id.removePrefix(
- INDEX_ID
- )
- )
- }
-
- if (id.startsWith(DIRECTORY_ID)) {
- return automotiveRepository.getDirectories(
- DIRECTORY_ID,
- id.removePrefix(
- DIRECTORY_ID
- )
- )
- }
-
- if (id.startsWith(PLAYLIST_ID)) {
- return automotiveRepository.getPlaylistSongs(
- id.removePrefix(
- PLAYLIST_ID
- )
- )
- }
-
- if (id.startsWith(ALBUM_ID)) {
- return automotiveRepository.getAlbumTracks(
- id.removePrefix(
- ALBUM_ID
- )
- )
- }
-
- if (id.startsWith(ARTIST_ID)) {
- return automotiveRepository.getArtistAlbum(
- ALBUM_ID,
- id.removePrefix(
- ARTIST_ID
- )
- )
- }
-
- return Futures.immediateFuture(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE))
- }
- }
- }
-
- // https://github.com/androidx/media/issues/156
- fun getItems(mediaItems: List): List {
- val updatedMediaItems = ArrayList()
-
- mediaItems.forEach {
- if (it.localConfiguration?.uri != null) {
- updatedMediaItems.add(it)
- } else {
- val sessionMediaItem = automotiveRepository.getSessionMediaItem(it.mediaId)
-
- if (sessionMediaItem != null) {
- var toAdd = automotiveRepository.getMetadatas(sessionMediaItem.timestamp!!)
- val index = toAdd.indexOfFirst { mediaItem -> mediaItem.mediaId == it.mediaId }
-
- toAdd = toAdd.subList(index, toAdd.size)
-
- updatedMediaItems.addAll(toAdd)
- }
- }
- }
-
- return updatedMediaItems
- }
-
- fun search(query: String): ListenableFuture>> {
- return automotiveRepository.search(
- query,
- ALBUM_ID,
- ARTIST_ID
- )
- }
-}
diff --git a/app/src/play/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt b/app/src/play/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt
deleted file mode 100644
index 099ae672..00000000
--- a/app/src/play/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt
+++ /dev/null
@@ -1,202 +0,0 @@
-package com.cappielloantonio.tempo.service
-
-import android.content.Context
-import android.os.Bundle
-import androidx.annotation.OptIn
-import androidx.media3.common.MediaItem
-import androidx.media3.common.Player
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.session.CommandButton
-import androidx.media3.session.LibraryResult
-import androidx.media3.session.MediaLibraryService
-import androidx.media3.session.MediaSession
-import androidx.media3.session.SessionCommand
-import androidx.media3.session.SessionResult
-import com.cappielloantonio.tempo.R
-import com.cappielloantonio.tempo.repository.AutomotiveRepository
-import com.google.common.collect.ImmutableList
-import com.google.common.util.concurrent.Futures
-import com.google.common.util.concurrent.ListenableFuture
-
-open class MediaLibrarySessionCallback(
- context: Context,
- automotiveRepository: AutomotiveRepository
-) :
- MediaLibraryService.MediaLibrarySession.Callback {
-
- init {
- MediaBrowserTree.initialize(automotiveRepository)
- }
-
- private val shuffleCommandButtons: List = listOf(
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_shuffle_on_description))
- .setSessionCommand(
- SessionCommand(
- CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY
- )
- ).setIconResId(R.drawable.exo_icon_shuffle_off).build(),
-
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_shuffle_off_description))
- .setSessionCommand(
- SessionCommand(
- CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY
- )
- ).setIconResId(R.drawable.exo_icon_shuffle_on).build()
- )
-
- private val repeatCommandButtons: List = listOf(
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_repeat_off_description))
- .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, Bundle.EMPTY))
- .setIconResId(R.drawable.exo_icon_repeat_off)
- .build(),
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_repeat_one_description))
- .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, Bundle.EMPTY))
- .setIconResId(R.drawable.exo_icon_repeat_one)
- .build(),
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_repeat_all_description))
- .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, Bundle.EMPTY))
- .setIconResId(R.drawable.exo_icon_repeat_all)
- .build()
- )
-
- private val customLayoutCommandButtons: List =
- shuffleCommandButtons + repeatCommandButtons
-
- @OptIn(UnstableApi::class)
- val mediaNotificationSessionCommands =
- MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
- .also { builder ->
- (shuffleCommandButtons + repeatCommandButtons).forEach { commandButton ->
- commandButton.sessionCommand?.let { builder.add(it) }
- }
- }.build()
-
- fun buildCustomLayout(player: Player): ImmutableList {
- val shuffle = shuffleCommandButtons[if (player.shuffleModeEnabled) 1 else 0]
- val repeat = when (player.repeatMode) {
- Player.REPEAT_MODE_ONE -> repeatCommandButtons[1]
- Player.REPEAT_MODE_ALL -> repeatCommandButtons[2]
- else -> repeatCommandButtons[0]
- }
- return ImmutableList.of(shuffle, repeat)
- }
-
- @OptIn(UnstableApi::class)
- override fun onConnect(
- session: MediaSession, controller: MediaSession.ControllerInfo
- ): MediaSession.ConnectionResult {
- if (session.isMediaNotificationController(controller) || session.isAutomotiveController(
- controller
- ) || session.isAutoCompanionController(controller)
- ) {
- val customLayout = buildCustomLayout(session.player)
-
- return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
- .setAvailableSessionCommands(mediaNotificationSessionCommands)
- .setCustomLayout(customLayout).build()
- }
-
- return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build()
- }
-
- @OptIn(UnstableApi::class)
- override fun onCustomCommand(
- session: MediaSession,
- controller: MediaSession.ControllerInfo,
- customCommand: SessionCommand,
- args: Bundle
- ): ListenableFuture {
- 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
- }
- else -> return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED))
- }
-
- session.setCustomLayout(
- session.mediaNotificationControllerInfo!!,
- buildCustomLayout(session.player)
- )
-
- return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
- }
-
- override fun onGetLibraryRoot(
- session: MediaLibraryService.MediaLibrarySession,
- browser: MediaSession.ControllerInfo,
- params: MediaLibraryService.LibraryParams?
- ): ListenableFuture> {
- return Futures.immediateFuture(LibraryResult.ofItem(MediaBrowserTree.getRootItem(), params))
- }
-
- override fun onGetChildren(
- session: MediaLibraryService.MediaLibrarySession,
- browser: MediaSession.ControllerInfo,
- parentId: String,
- page: Int,
- pageSize: Int,
- params: MediaLibraryService.LibraryParams?
- ): ListenableFuture>> {
- return MediaBrowserTree.getChildren(parentId)
- }
-
- override fun onAddMediaItems(
- mediaSession: MediaSession,
- controller: MediaSession.ControllerInfo,
- mediaItems: List
- ): ListenableFuture> {
- return super.onAddMediaItems(
- mediaSession,
- controller,
- MediaBrowserTree.getItems(mediaItems)
- )
- }
-
- override fun onSearch(
- session: MediaLibraryService.MediaLibrarySession,
- browser: MediaSession.ControllerInfo,
- query: String,
- params: MediaLibraryService.LibraryParams?
- ): ListenableFuture> {
- session.notifySearchResultChanged(browser, query, 60, params)
- return Futures.immediateFuture(LibraryResult.ofVoid())
- }
-
- override fun onGetSearchResult(
- session: MediaLibraryService.MediaLibrarySession,
- browser: MediaSession.ControllerInfo,
- query: String,
- page: Int,
- pageSize: Int,
- params: MediaLibraryService.LibraryParams?
- ): ListenableFuture>> {
- return MediaBrowserTree.search(query)
- }
-
- 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"
- }
-}
\ No newline at end of file
diff --git a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt
deleted file mode 100644
index 2391a2bb..00000000
--- a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt
+++ /dev/null
@@ -1,229 +0,0 @@
-package com.cappielloantonio.tempo.service
-
-import android.app.PendingIntent.FLAG_IMMUTABLE
-import android.app.PendingIntent.FLAG_UPDATE_CURRENT
-import android.app.TaskStackBuilder
-import android.content.Intent
-import androidx.media3.cast.CastPlayer
-import androidx.media3.cast.SessionAvailabilityListener
-import androidx.media3.common.AudioAttributes
-import androidx.media3.common.C
-import androidx.media3.common.MediaItem
-import androidx.media3.common.Player
-import androidx.media3.common.Tracks
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.exoplayer.DefaultLoadControl
-import androidx.media3.exoplayer.ExoPlayer
-import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
-import androidx.media3.session.MediaLibraryService
-import androidx.media3.session.MediaSession.ControllerInfo
-import com.cappielloantonio.tempo.repository.AutomotiveRepository
-import com.cappielloantonio.tempo.ui.activity.MainActivity
-import com.cappielloantonio.tempo.util.Constants
-import com.cappielloantonio.tempo.util.DownloadUtil
-import com.cappielloantonio.tempo.util.Preferences
-import com.cappielloantonio.tempo.util.ReplayGainUtil
-import com.google.android.gms.cast.framework.CastContext
-import com.google.android.gms.common.ConnectionResult
-import com.google.android.gms.common.GoogleApiAvailability
-
-@UnstableApi
-class MediaService : MediaLibraryService(), SessionAvailabilityListener {
- private lateinit var automotiveRepository: AutomotiveRepository
- private lateinit var player: ExoPlayer
- private lateinit var castPlayer: CastPlayer
- private lateinit var mediaLibrarySession: MediaLibrarySession
- private lateinit var librarySessionCallback: MediaLibrarySessionCallback
-
- override fun onCreate() {
- super.onCreate()
-
- initializeRepository()
- initializePlayer()
- initializeCastPlayer()
- initializeMediaLibrarySession()
- initializePlayerListener()
-
- setPlayer(
- null,
- if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player
- )
- }
-
- override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
- return mediaLibrarySession
- }
-
- override fun onTaskRemoved(rootIntent: Intent?) {
- val player = mediaLibrarySession.player
-
- if (!player.playWhenReady || player.mediaItemCount == 0) {
- stopSelf()
- }
- }
-
- override fun onDestroy() {
- releasePlayer()
- super.onDestroy()
- }
-
- private fun initializeRepository() {
- automotiveRepository = AutomotiveRepository()
- }
-
- private fun initializePlayer() {
- player = ExoPlayer.Builder(this)
- .setRenderersFactory(getRenderersFactory())
- .setMediaSourceFactory(getMediaSourceFactory())
- .setAudioAttributes(AudioAttributes.DEFAULT, true)
- .setHandleAudioBecomingNoisy(true)
- .setWakeMode(C.WAKE_MODE_NETWORK)
- .setLoadControl(initializeLoadControl())
- .build()
-
- player.shuffleModeEnabled = Preferences.isShuffleModeEnabled()
- player.repeatMode = Preferences.getRepeatMode()
- }
-
- private fun initializeCastPlayer() {
- if (GoogleApiAvailability.getInstance()
- .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
- ) {
- castPlayer = CastPlayer(CastContext.getSharedInstance(this))
- castPlayer.setSessionAvailabilityListener(this)
- }
- }
-
- private fun initializeMediaLibrarySession() {
- val sessionActivityPendingIntent =
- TaskStackBuilder.create(this).run {
- addNextIntent(Intent(this@MediaService, MainActivity::class.java))
- getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
- }
-
- librarySessionCallback = createLibrarySessionCallback()
- mediaLibrarySession =
- MediaLibrarySession.Builder(this, player, librarySessionCallback)
- .setSessionActivity(sessionActivityPendingIntent)
- .build()
- }
-
- private fun createLibrarySessionCallback(): MediaLibrarySessionCallback {
- return MediaLibrarySessionCallback(this, automotiveRepository)
- }
-
- private fun initializePlayerListener() {
- player.addListener(object : Player.Listener {
- override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
- if (mediaItem == null) return
-
- if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
- MediaManager.setLastPlayedTimestamp(mediaItem)
- }
- }
-
- override fun onTracksChanged(tracks: Tracks) {
- ReplayGainUtil.setReplayGain(player, tracks)
- MediaManager.scrobble(player.currentMediaItem, false)
-
- if (player.currentMediaItemIndex + 1 == player.mediaItemCount)
- MediaManager.continuousPlay(player.currentMediaItem)
- }
-
- override fun onIsPlayingChanged(isPlaying: Boolean) {
- if (!isPlaying) {
- MediaManager.setPlayingPausedTimestamp(
- player.currentMediaItem,
- player.currentPosition
- )
- } else {
- MediaManager.scrobble(player.currentMediaItem, false)
- }
- }
-
- override fun onPlaybackStateChanged(playbackState: Int) {
- 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)
- }
- }
-
- override fun onPositionDiscontinuity(
- oldPosition: Player.PositionInfo,
- newPosition: Player.PositionInfo,
- reason: Int
- ) {
- 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)
- mediaLibrarySession.setCustomLayout(
- librarySessionCallback.buildCustomLayout(player)
- )
- }
-
- override fun onRepeatModeChanged(repeatMode: Int) {
- Preferences.setRepeatMode(repeatMode)
- mediaLibrarySession.setCustomLayout(
- librarySessionCallback.buildCustomLayout(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 setPlayer(oldPlayer: Player?, newPlayer: Player) {
- if (oldPlayer === newPlayer) return
- oldPlayer?.stop()
- mediaLibrarySession.player = newPlayer
- }
-
- private fun releasePlayer() {
- if (this::castPlayer.isInitialized) castPlayer.setSessionAvailabilityListener(null)
- if (this::castPlayer.isInitialized) castPlayer.release()
- player.release()
- mediaLibrarySession.release()
- automotiveRepository.deleteMetadata()
- clearListener()
- }
-
- private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
-
- private fun getMediaSourceFactory() =
- DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this))
-
- override fun onCastSessionAvailable() {
- setPlayer(player, castPlayer)
- }
-
- override fun onCastSessionUnavailable() {
- setPlayer(castPlayer, player)
- }
-}
\ No newline at end of file
diff --git a/app/src/play/java/com/cappielloantonio/tempo/ui/fragment/ToolbarFragment.java b/app/src/play/java/com/cappielloantonio/tempo/ui/fragment/ToolbarFragment.java
deleted file mode 100644
index d3f36bb7..00000000
--- a/app/src/play/java/com/cappielloantonio/tempo/ui/fragment/ToolbarFragment.java
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.cappielloantonio.tempo.ui.fragment;
-
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.fragment.app.Fragment;
-import androidx.media3.common.util.UnstableApi;
-
-import com.cappielloantonio.tempo.R;
-import com.cappielloantonio.tempo.databinding.FragmentToolbarBinding;
-import com.cappielloantonio.tempo.ui.activity.MainActivity;
-import com.google.android.gms.cast.framework.CastButtonFactory;
-
-@UnstableApi
-public class ToolbarFragment extends Fragment {
- private static final String TAG = "ToolbarFragment";
-
- private FragmentToolbarBinding bind;
- private MainActivity activity;
-
- public ToolbarFragment() {
- // Required empty public constructor
- }
-
- @Override
- public void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setHasOptionsMenu(true);
- }
-
- @Override
- public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
- super.onCreateOptionsMenu(menu, inflater);
- inflater.inflate(R.menu.main_page_menu, menu);
- CastButtonFactory.setUpMediaRouteButton(requireContext(), menu, R.id.media_route_menu_item);
- }
-
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
- activity = (MainActivity) getActivity();
-
- bind = FragmentToolbarBinding.inflate(inflater, container, false);
- View view = bind.getRoot();
-
- return view;
- }
-
- @Override
- public boolean onOptionsItemSelected(@NonNull MenuItem item) {
- if (item.getItemId() == R.id.action_search) {
- activity.navController.navigate(R.id.searchFragment);
- return true;
- } else if (item.getItemId() == R.id.action_settings) {
- activity.navController.navigate(R.id.settingsFragment);
- return true;
- }
-
- return false;
- }
-}
\ No newline at end of file
diff --git a/app/src/play/java/com/cappielloantonio/tempo/util/Flavors.java b/app/src/play/java/com/cappielloantonio/tempo/util/Flavors.java
deleted file mode 100644
index 4bed2921..00000000
--- a/app/src/play/java/com/cappielloantonio/tempo/util/Flavors.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.cappielloantonio.tempo.util;
-
-import android.content.Context;
-
-import com.google.android.gms.cast.framework.CastContext;
-import com.google.android.gms.common.ConnectionResult;
-import com.google.android.gms.common.GoogleApiAvailability;
-
-public class Flavors {
- public static void initializeCastContext(Context context) {
- if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS)
- CastContext.getSharedInstance(context);
- }
-}
diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt
index 099ae672..1bc0b15f 100644
--- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt
+++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt
@@ -2,21 +2,42 @@ package com.cappielloantonio.tempo.service
import android.content.Context
import android.os.Bundle
+import android.util.Log
import androidx.annotation.OptIn
+import androidx.concurrent.futures.CallbackToFutureAdapter
+import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
+import androidx.media3.common.Rating
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.LibraryResult
+import androidx.media3.session.MediaConstants
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
+import androidx.media3.session.SessionError
import androidx.media3.session.SessionResult
+import com.cappielloantonio.tempo.App
import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.repository.AutomotiveRepository
+import com.cappielloantonio.tempo.subsonic.base.ApiResponse
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_LOADING
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON
import com.google.common.collect.ImmutableList
+import com.cappielloantonio.tempo.util.Preferences
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
open class MediaLibrarySessionCallback(
context: Context,
@@ -28,82 +49,244 @@ open class MediaLibrarySessionCallback(
MediaBrowserTree.initialize(automotiveRepository)
}
- private val shuffleCommandButtons: List = listOf(
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_shuffle_on_description))
- .setSessionCommand(
- SessionCommand(
- CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY
- )
- ).setIconResId(R.drawable.exo_icon_shuffle_off).build(),
+ private val customCommandToggleShuffleModeOn = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.exo_controls_shuffle_on_description))
+ .setSessionCommand(
+ SessionCommand(
+ CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY
+ )
+ ).setIconResId(R.drawable.exo_icon_shuffle_off).build()
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_shuffle_off_description))
- .setSessionCommand(
- SessionCommand(
- CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY
- )
- ).setIconResId(R.drawable.exo_icon_shuffle_on).build()
+ private val customCommandToggleShuffleModeOff = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.exo_controls_shuffle_off_description))
+ .setSessionCommand(
+ SessionCommand(
+ CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY
+ )
+ ).setIconResId(R.drawable.exo_icon_shuffle_on).build()
+
+ private val customCommandToggleRepeatModeOff = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.exo_controls_repeat_off_description))
+ .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, Bundle.EMPTY))
+ .setIconResId(R.drawable.exo_icon_repeat_off)
+ .build()
+
+ private val customCommandToggleRepeatModeOne = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.exo_controls_repeat_one_description))
+ .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, Bundle.EMPTY))
+ .setIconResId(R.drawable.exo_icon_repeat_one)
+ .build()
+
+ private val customCommandToggleRepeatModeAll = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.exo_controls_repeat_all_description))
+ .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, Bundle.EMPTY))
+ .setIconResId(R.drawable.exo_icon_repeat_all)
+ .build()
+
+ private val customCommandToggleHeartOn = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.exo_controls_heart_on_description))
+ .setSessionCommand(
+ SessionCommand(
+ CUSTOM_COMMAND_TOGGLE_HEART_ON, Bundle.EMPTY
+ )
+ )
+ .setIconResId(R.drawable.ic_favorite)
+ .build()
+
+ private val customCommandToggleHeartOff = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.exo_controls_heart_off_description))
+ .setSessionCommand(
+ SessionCommand(CUSTOM_COMMAND_TOGGLE_HEART_OFF, Bundle.EMPTY)
+ )
+ .setIconResId(R.drawable.ic_favorites_outlined)
+ .build()
+
+ // Fake Command while waiting for like update command
+ private val customCommandToggleHeartLoading = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.cast_expanded_controller_loading))
+ .setSessionCommand(
+ SessionCommand(CUSTOM_COMMAND_TOGGLE_HEART_LOADING, Bundle.EMPTY)
+ )
+ .setIconResId(R.drawable.ic_bookmark_sync)
+ .build()
+
+ private val customLayoutCommandButtons = listOf(
+ customCommandToggleShuffleModeOn,
+ customCommandToggleShuffleModeOff,
+ customCommandToggleRepeatModeOff,
+ customCommandToggleRepeatModeOne,
+ customCommandToggleRepeatModeAll,
+ customCommandToggleHeartOn,
+ customCommandToggleHeartOff,
+ customCommandToggleHeartLoading,
)
- private val repeatCommandButtons: List = listOf(
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_repeat_off_description))
- .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, Bundle.EMPTY))
- .setIconResId(R.drawable.exo_icon_repeat_off)
- .build(),
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_repeat_one_description))
- .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, Bundle.EMPTY))
- .setIconResId(R.drawable.exo_icon_repeat_one)
- .build(),
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_repeat_all_description))
- .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, Bundle.EMPTY))
- .setIconResId(R.drawable.exo_icon_repeat_all)
- .build()
- )
-
- private val customLayoutCommandButtons: List =
- shuffleCommandButtons + repeatCommandButtons
-
@OptIn(UnstableApi::class)
val mediaNotificationSessionCommands =
MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
.also { builder ->
- (shuffleCommandButtons + repeatCommandButtons).forEach { commandButton ->
+ customLayoutCommandButtons.forEach { commandButton ->
commandButton.sessionCommand?.let { builder.add(it) }
}
}.build()
- fun buildCustomLayout(player: Player): ImmutableList {
- val shuffle = shuffleCommandButtons[if (player.shuffleModeEnabled) 1 else 0]
- val repeat = when (player.repeatMode) {
- Player.REPEAT_MODE_ONE -> repeatCommandButtons[1]
- Player.REPEAT_MODE_ALL -> repeatCommandButtons[2]
- else -> repeatCommandButtons[0]
- }
- return ImmutableList.of(shuffle, repeat)
- }
-
@OptIn(UnstableApi::class)
override fun onConnect(
session: MediaSession, controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
+ session.player.addListener(object : Player.Listener {
+ override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
+ updateMediaNotificationCustomLayout(session)
+ }
+
+ override fun onRepeatModeChanged(repeatMode: Int) {
+ updateMediaNotificationCustomLayout(session)
+ }
+
+ override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
+ updateMediaNotificationCustomLayout(session)
+ }
+
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
+ updateMediaNotificationCustomLayout(session)
+ }
+ })
+
+ // FIXME: I'm not sure this if is required anymore
if (session.isMediaNotificationController(controller) || session.isAutomotiveController(
controller
) || session.isAutoCompanionController(controller)
) {
- val customLayout = buildCustomLayout(session.player)
-
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(mediaNotificationSessionCommands)
- .setCustomLayout(customLayout).build()
+ .setCustomLayout(buildCustomLayout(session.player))
+ .build()
}
return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build()
}
+ // Update the mediaNotification after some changes
+ @OptIn(UnstableApi::class)
+ private fun updateMediaNotificationCustomLayout(
+ session: MediaSession,
+ isRatingPending: Boolean = false
+ ) {
+ session.setCustomLayout(
+ session.mediaNotificationControllerInfo!!,
+ buildCustomLayout(session.player, isRatingPending)
+ )
+ }
+
+ private fun buildCustomLayout(player: Player, isRatingPending: Boolean = false): ImmutableList {
+ val customLayout = mutableListOf()
+
+ val showShuffle = Preferences.showShuffleInsteadOfHeart()
+
+ if (!showShuffle) {
+ if (player.currentMediaItem != null && !isRatingPending) {
+ if ((player.mediaMetadata.userRating as HeartRating?)?.isHeart == true) {
+ customLayout.add(customCommandToggleHeartOn)
+ } else {
+ customLayout.add(customCommandToggleHeartOff)
+ }
+ }
+ } else {
+ customLayout.add(
+ if (player.shuffleModeEnabled) customCommandToggleShuffleModeOff else customCommandToggleShuffleModeOn
+ )
+ }
+
+ // Add repeat button
+ val repeatButton = when (player.repeatMode) {
+ Player.REPEAT_MODE_ONE -> customCommandToggleRepeatModeOne
+ Player.REPEAT_MODE_ALL -> customCommandToggleRepeatModeAll
+ else -> customCommandToggleRepeatModeOff
+ }
+
+ customLayout.add(repeatButton)
+ return ImmutableList.copyOf(customLayout)
+ }
+
+ // Setting rating without a mediaId will set the currently listened mediaId
+ override fun onSetRating(
+ session: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ rating: Rating
+ ): ListenableFuture {
+ return onSetRating(session, controller, session.player.currentMediaItem!!.mediaId, rating)
+ }
+
+ override fun onSetRating(
+ session: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ mediaId: String,
+ rating: Rating
+ ): ListenableFuture {
+ val isStaring = (rating as HeartRating).isHeart
+
+ val networkCall = if (isStaring)
+ App.getSubsonicClientInstance(false)
+ .mediaAnnotationClient
+ .star(mediaId, null, null)
+ else
+ App.getSubsonicClientInstance(false)
+ .mediaAnnotationClient
+ .unstar(mediaId, null, null)
+
+ return CallbackToFutureAdapter.getFuture { completer ->
+ networkCall.enqueue(object : Callback {
+ @OptIn(UnstableApi::class)
+ override fun onResponse(
+ call: Call,
+ response: Response
+ ) {
+ if (response.isSuccessful) {
+
+ // Search if the media item in the player should be updated
+ for (i in 0 until session.player.mediaItemCount) {
+ val mediaItem = session.player.getMediaItemAt(i)
+ if (mediaItem.mediaId == mediaId) {
+ val newMetadata = mediaItem.mediaMetadata.buildUpon()
+ .setUserRating(HeartRating(isStaring)).build()
+ session.player.replaceMediaItem(
+ i,
+ mediaItem.buildUpon().setMediaMetadata(newMetadata).build()
+ )
+ }
+ }
+
+ updateMediaNotificationCustomLayout(session)
+ completer.set(SessionResult(SessionResult.RESULT_SUCCESS))
+ } else {
+ updateMediaNotificationCustomLayout(session)
+ completer.set(
+ SessionResult(
+ SessionError(
+ response.code(),
+ response.message()
+ )
+ )
+ )
+ }
+ }
+
+ @OptIn(UnstableApi::class)
+ override fun onFailure(call: Call, t: Throwable) {
+ updateMediaNotificationCustomLayout(session)
+ completer.set(
+ SessionResult(
+ SessionError(
+ SessionError.ERROR_UNKNOWN,
+ "An error as occurred"
+ )
+ )
+ )
+ }
+ })
+ }
+ }
+
@OptIn(UnstableApi::class)
override fun onCustomCommand(
session: MediaSession,
@@ -111,9 +294,18 @@ open class MediaLibrarySessionCallback(
customCommand: SessionCommand,
args: Bundle
): ListenableFuture {
+
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_SHUFFLE_MODE_ON -> {
+ session.player.shuffleModeEnabled = true
+ updateMediaNotificationCustomLayout(session)
+ return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
+ }
+ CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> {
+ session.player.shuffleModeEnabled = false
+ updateMediaNotificationCustomLayout(session)
+ return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
+ }
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> {
@@ -123,16 +315,31 @@ open class MediaLibrarySessionCallback(
else -> Player.REPEAT_MODE_OFF
}
session.player.repeatMode = nextMode
+ updateMediaNotificationCustomLayout(session)
+ return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
- else -> return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED))
+ CUSTOM_COMMAND_TOGGLE_HEART_ON,
+ CUSTOM_COMMAND_TOGGLE_HEART_OFF -> {
+ val currentRating = session.player.mediaMetadata.userRating as? HeartRating
+ val isCurrentlyLiked = currentRating?.isHeart ?: false
+
+ val newLikedState = !isCurrentlyLiked
+
+ updateMediaNotificationCustomLayout(
+ session,
+ isRatingPending = true // Show loading state
+ )
+ return onSetRating(session, controller, HeartRating(newLikedState))
+ }
+ else -> return Futures.immediateFuture(
+ SessionResult(
+ SessionError(
+ SessionError.ERROR_NOT_SUPPORTED,
+ customCommand.customAction
+ )
+ )
+ )
}
-
- session.setCustomLayout(
- session.mediaNotificationControllerInfo!!,
- buildCustomLayout(session.player)
- )
-
- return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
override fun onGetLibraryRoot(
@@ -186,17 +393,4 @@ open class MediaLibrarySessionCallback(
): ListenableFuture>> {
return MediaBrowserTree.search(query)
}
-
- 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"
- }
-}
\ No newline at end of file
+}
diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt
index 2391a2bb..2ff81ac4 100644
--- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt
+++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt
@@ -4,6 +4,11 @@ import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.TaskStackBuilder
import android.content.Intent
+import android.os.Binder
+import android.os.IBinder
+import android.os.Handler
+import android.os.Looper
+import androidx.core.content.ContextCompat
import androidx.media3.cast.CastPlayer
import androidx.media3.cast.SessionAvailabilityListener
import androidx.media3.common.AudioAttributes
@@ -14,15 +19,19 @@ import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
-import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession.ControllerInfo
import com.cappielloantonio.tempo.repository.AutomotiveRepository
+import com.cappielloantonio.tempo.repository.QueueRepository
import com.cappielloantonio.tempo.ui.activity.MainActivity
+import com.cappielloantonio.tempo.util.AssetLinkUtil
import com.cappielloantonio.tempo.util.Constants
import com.cappielloantonio.tempo.util.DownloadUtil
+import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
+import com.cappielloantonio.tempo.util.MappingUtil
import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil
+import com.cappielloantonio.tempo.widget.WidgetUpdateManager
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
@@ -34,19 +43,46 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
private lateinit var castPlayer: CastPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var librarySessionCallback: MediaLibrarySessionCallback
+ lateinit var equalizerManager: EqualizerManager
+
+ inner class LocalBinder : Binder() {
+ fun getEqualizerManager(): EqualizerManager {
+ return this@MediaService.equalizerManager
+ }
+ }
+
+ private val binder = LocalBinder()
+
+ companion object {
+ const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER"
+ }
+ private val widgetUpdateHandler = Handler(Looper.getMainLooper())
+ private var widgetUpdateScheduled = false
+ private val widgetUpdateRunnable = object : Runnable {
+ override fun run() {
+ if (!player.isPlaying) {
+ widgetUpdateScheduled = false
+ return
+ }
+ updateWidget()
+ widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
+ }
+ }
override fun onCreate() {
super.onCreate()
initializeRepository()
initializePlayer()
- initializeCastPlayer()
initializeMediaLibrarySession()
+ restorePlayerFromQueue()
initializePlayerListener()
+ initializeCastPlayer()
+ initializeEqualizerManager()
setPlayer(
- null,
- if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player
+ null,
+ if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player
)
}
@@ -63,18 +99,44 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
}
override fun onDestroy() {
+ equalizerManager.release()
+ stopWidgetUpdates()
releasePlayer()
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 initializeRepository() {
automotiveRepository = AutomotiveRepository()
}
+ private fun initializeEqualizerManager() {
+ equalizerManager = EqualizerManager()
+ val audioSessionId = player.audioSessionId
+ if (equalizerManager.attachToSession(audioSessionId)) {
+ 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])
+ }
+ }
+ }
+
private fun initializePlayer() {
player = ExoPlayer.Builder(this)
.setRenderersFactory(getRenderersFactory())
- .setMediaSourceFactory(getMediaSourceFactory())
+ .setMediaSourceFactory(DynamicMediaSourceFactory(this))
.setAudioAttributes(AudioAttributes.DEFAULT, true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
@@ -85,27 +147,62 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
player.repeatMode = Preferences.getRepeatMode()
}
+ @Suppress("DEPRECATION")
private fun initializeCastPlayer() {
if (GoogleApiAvailability.getInstance()
- .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
+ .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
) {
- castPlayer = CastPlayer(CastContext.getSharedInstance(this))
- castPlayer.setSessionAvailabilityListener(this)
+ CastContext.getSharedInstance(this, ContextCompat.getMainExecutor(this))
+ .addOnSuccessListener { castContext ->
+ castPlayer = CastPlayer(castContext)
+ castPlayer.setSessionAvailabilityListener(this@MediaService)
+
+ if (castPlayer.isCastSessionAvailable && this::mediaLibrarySession.isInitialized) {
+ setPlayer(player, castPlayer)
+ }
+ }
}
}
private fun initializeMediaLibrarySession() {
val sessionActivityPendingIntent =
- TaskStackBuilder.create(this).run {
- addNextIntent(Intent(this@MediaService, MainActivity::class.java))
- getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
- }
+ TaskStackBuilder.create(this).run {
+ addNextIntent(Intent(this@MediaService, MainActivity::class.java))
+ getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
+ }
librarySessionCallback = createLibrarySessionCallback()
mediaLibrarySession =
- MediaLibrarySession.Builder(this, player, librarySessionCallback)
- .setSessionActivity(sessionActivityPendingIntent)
- .build()
+ MediaLibrarySession.Builder(this, player, librarySessionCallback)
+ .setSessionActivity(sessionActivityPendingIntent)
+ .build()
+ }
+
+ private fun restorePlayerFromQueue() {
+ 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()
}
private fun createLibrarySessionCallback(): MediaLibrarySessionCallback {
@@ -120,11 +217,16 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem)
}
+ updateWidget()
}
override fun onTracksChanged(tracks: Tracks) {
ReplayGainUtil.setReplayGain(player, tracks)
- MediaManager.scrobble(player.currentMediaItem, false)
+
+ val currentMediaItem = player.currentMediaItem
+ if (currentMediaItem != null && currentMediaItem.mediaMetadata.extras != null) {
+ MediaManager.scrobble(currentMediaItem, false)
+ }
if (player.currentMediaItemIndex + 1 == player.mediaItemCount)
MediaManager.continuousPlay(player.currentMediaItem)
@@ -133,30 +235,37 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (!isPlaying) {
MediaManager.setPlayingPausedTimestamp(
- player.currentMediaItem,
- player.currentPosition
+ player.currentMediaItem,
+ player.currentPosition
)
} else {
MediaManager.scrobble(player.currentMediaItem, false)
}
+ if (isPlaying) {
+ scheduleWidgetUpdates()
+ } else {
+ stopWidgetUpdates()
+ }
+ updateWidget()
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
if (!player.hasNextMediaItem() &&
- playbackState == Player.STATE_ENDED &&
- player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
+ playbackState == Player.STATE_ENDED &&
+ player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
) {
MediaManager.scrobble(player.currentMediaItem, true)
MediaManager.saveChronology(player.currentMediaItem)
}
+ updateWidget()
}
override fun onPositionDiscontinuity(
- oldPosition: Player.PositionInfo,
- newPosition: Player.PositionInfo,
- reason: Int
+ oldPosition: Player.PositionInfo,
+ newPosition: Player.PositionInfo,
+ reason: Int
) {
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
@@ -174,29 +283,81 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
- mediaLibrarySession.setCustomLayout(
- librarySessionCallback.buildCustomLayout(player)
- )
}
override fun onRepeatModeChanged(repeatMode: Int) {
Preferences.setRepeatMode(repeatMode)
- mediaLibrarySession.setCustomLayout(
- librarySessionCallback.buildCustomLayout(player)
- )
}
})
+ if (player.isPlaying) {
+ scheduleWidgetUpdates()
+ }
+ }
+
+ private fun updateWidget() {
+ 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 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()
+ .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 getQueueFromPlayer(player: Player): List {
+ val queue = mutableListOf()
+ for (i in 0 until player.mediaItemCount) {
+ queue.add(player.getMediaItemAt(i))
+ }
+ return queue
}
private fun setPlayer(oldPlayer: Player?, newPlayer: Player) {
@@ -211,19 +372,35 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
player.release()
mediaLibrarySession.release()
automotiveRepository.deleteMetadata()
- clearListener()
}
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
- private fun getMediaSourceFactory() =
- DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this))
-
override fun onCastSessionAvailable() {
+ val currentQueue = getQueueFromPlayer(player)
+ val currentIndex = player.currentMediaItemIndex
+ val currentPosition = player.currentPosition
+ val isPlaying = player.playWhenReady
+
setPlayer(player, castPlayer)
+
+ castPlayer.setMediaItems(currentQueue, currentIndex, currentPosition)
+ castPlayer.playWhenReady = isPlaying
+ castPlayer.prepare()
}
override fun onCastSessionUnavailable() {
+ val currentQueue = getQueueFromPlayer(castPlayer)
+ val currentIndex = castPlayer.currentMediaItemIndex
+ val currentPosition = castPlayer.currentPosition
+ val isPlaying = castPlayer.playWhenReady
+
setPlayer(castPlayer, player)
+
+ player.setMediaItems(currentQueue, currentIndex, currentPosition)
+ player.playWhenReady = isPlaying
+ player.prepare()
}
-}
\ No newline at end of file
+}
+
+private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/util/Flavors.java b/app/src/tempo/java/com/cappielloantonio/tempo/util/Flavors.java
index 4bed2921..1ec0cd92 100644
--- a/app/src/tempo/java/com/cappielloantonio/tempo/util/Flavors.java
+++ b/app/src/tempo/java/com/cappielloantonio/tempo/util/Flavors.java
@@ -2,13 +2,16 @@ package com.cappielloantonio.tempo.util;
import android.content.Context;
+import androidx.core.content.ContextCompat;
+
import com.google.android.gms.cast.framework.CastContext;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;
public class Flavors {
+ @SuppressWarnings("deprecation")
public static void initializeCastContext(Context context) {
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS)
- CastContext.getSharedInstance(context);
+ CastContext.getSharedInstance(context, ContextCompat.getMainExecutor(context));
}
}
diff --git a/mockup/usage/player_icons.png b/mockup/usage/player_icons.png
new file mode 100644
index 00000000..59d6fa4f
Binary files /dev/null and b/mockup/usage/player_icons.png differ