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 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 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" /> -