mirror of
https://github.com/antebudimir/tempus.git
synced 2025-12-31 09:33:33 +00:00
Merge branch 'main' into patch-1
This commit is contained in:
commit
c028c52576
158 changed files with 9807 additions and 1722 deletions
64
.github/workflows/github_release.yml
vendored
64
.github/workflows/github_release.yml
vendored
|
|
@ -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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -18,3 +18,4 @@
|
|||
# release / debug files
|
||||
tempus-release-key.jks
|
||||
app/tempo/
|
||||
app/notquitemy/
|
||||
|
|
|
|||
86
CHANGELOG.md
86
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
|
||||
|
|
|
|||
31
README.md
31
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
|
||||
|
||||
<p align="center">
|
||||
|
|
@ -87,6 +98,16 @@ Tempo is an open-source project developed and maintained solely by me. I would l
|
|||
<img src="mockup/dark/8_screenshot.png" width=200>
|
||||
</p>
|
||||
|
||||
## Contributing
|
||||
|
||||
Please fork and open PR's against the development branch. Make sure your PR builds successfully.
|
||||
|
||||
If there is an UI change, please include a before/after screenshot and a short video/gif if that helps elaborating the fix/feature in the PR.
|
||||
|
||||
Currently there are no tests but I would love to start on some unit tests.
|
||||
|
||||
Not a hard requirement but any new feature/change should ideally include an update to the nacent documention.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
24
USAGE.md
24
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.
|
||||
<p align="left">
|
||||
<img src="mockup/usage/player_icons.png" width=159>
|
||||
</p>
|
||||
|
||||
*marked the icons with numbers for clarity*
|
||||
|
||||
1. Downloads the track (there is a notification if the android screen but not a pop toast currently )
|
||||
2. Adds track to playlist - pops up playlist dialog.
|
||||
3. Adds tracks to the queue via instant mix function
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
1151
app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json
Normal file
1151
app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -42,6 +42,16 @@
|
|||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="asset"
|
||||
android:scheme="tempo" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
|
|
@ -73,5 +83,20 @@
|
|||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".widget.WidgetProvider4x1"
|
||||
android:exported="false"
|
||||
android:label="@string/widget_label">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_info"/>
|
||||
</receiver>
|
||||
|
||||
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ public interface DownloadDao {
|
|||
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
|
||||
LiveData<List<Download>> getAll();
|
||||
|
||||
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
|
||||
List<Download> 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<String> ids);
|
||||
|
||||
@Query("DELETE FROM download")
|
||||
void deleteAll();
|
||||
}
|
||||
|
|
@ -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<LyricsCache> observeOne(String songId);
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
void insert(LyricsCache lyricsCache);
|
||||
|
||||
@Query("DELETE FROM lyrics_cache WHERE song_id = :songId")
|
||||
void delete(String songId);
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Bitmap> 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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
package com.cappielloantonio.tempo.glide;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.bumptech.glide.Priority;
|
||||
import com.bumptech.glide.load.DataSource;
|
||||
import com.bumptech.glide.load.Options;
|
||||
import com.bumptech.glide.load.data.DataFetcher;
|
||||
import com.bumptech.glide.load.model.ModelLoader;
|
||||
import com.bumptech.glide.load.model.ModelLoaderFactory;
|
||||
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
|
||||
import com.bumptech.glide.signature.ObjectKey;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
|
||||
public class IPv6StringLoader implements ModelLoader<String, InputStream> {
|
||||
private static final int DEFAULT_TIMEOUT_MS = 2500;
|
||||
|
||||
@Override
|
||||
public boolean handles(@NonNull String model) {
|
||||
return model.startsWith("http://") || model.startsWith("https://");
|
||||
}
|
||||
|
||||
@Override
|
||||
public LoadData<InputStream> buildLoadData(@NonNull String model, int width, int height, @NonNull Options options) {
|
||||
if (!handles(model)) {
|
||||
return null;
|
||||
}
|
||||
return new LoadData<>(new ObjectKey(model), new IPv6StreamFetcher(model));
|
||||
}
|
||||
|
||||
private static class IPv6StreamFetcher implements DataFetcher<InputStream> {
|
||||
private final String model;
|
||||
private InputStream stream;
|
||||
private HttpURLConnection connection;
|
||||
|
||||
IPv6StreamFetcher(String model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) {
|
||||
try {
|
||||
URL url = new URL(model);
|
||||
connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setConnectTimeout(DEFAULT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(DEFAULT_TIMEOUT_MS);
|
||||
connection.setUseCaches(true);
|
||||
connection.setDoInput(true);
|
||||
connection.connect();
|
||||
|
||||
if (connection.getResponseCode() / 100 != 2) {
|
||||
callback.onLoadFailed(new IOException("Request failed with status code: " + connection.getResponseCode()));
|
||||
return;
|
||||
}
|
||||
|
||||
stream = connection.getInputStream();
|
||||
callback.onDataReady(stream);
|
||||
} catch (IOException e) {
|
||||
callback.onLoadFailed(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup() {
|
||||
if (stream != null) {
|
||||
try {
|
||||
stream.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancel() {
|
||||
// HttpURLConnection does not provide a direct cancel mechanism.
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Class<InputStream> getDataClass() {
|
||||
return InputStream.class;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public DataSource getDataSource() {
|
||||
return DataSource.REMOTE;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Factory implements ModelLoaderFactory<String, InputStream> {
|
||||
@NonNull
|
||||
@Override
|
||||
public ModelLoader<String, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) {
|
||||
return new IPv6StringLoader();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void teardown() {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,18 +8,18 @@ import androidx.room.PrimaryKey
|
|||
import com.cappielloantonio.tempo.subsonic.models.Child
|
||||
import com.cappielloantonio.tempo.util.Preferences
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
@Entity(tableName = "chronology")
|
||||
class Chronology(@PrimaryKey override val id: String) : Child(id) {
|
||||
class Chronology(
|
||||
@PrimaryKey override val id: String,
|
||||
@ColumnInfo(name = "timestamp")
|
||||
var timestamp: Long = System.currentTimeMillis()
|
||||
|
||||
var timestamp: Long = System.currentTimeMillis(),
|
||||
@ColumnInfo(name = "server")
|
||||
var server: String? = null
|
||||
|
||||
var server: String? = null,
|
||||
) : Child(id) {
|
||||
constructor(mediaItem: MediaItem) : this(mediaItem.mediaMetadata.extras!!.getString("id")!!) {
|
||||
parentId = mediaItem.mediaMetadata.extras!!.getString("parentId")
|
||||
isDir = mediaItem.mediaMetadata.extras!!.getBoolean("isDir")
|
||||
|
|
|
|||
|
|
@ -10,19 +10,17 @@ import kotlinx.parcelize.Parcelize
|
|||
@Keep
|
||||
@Parcelize
|
||||
@Entity(tableName = "download")
|
||||
class Download(@PrimaryKey override val id: String) : Child(id) {
|
||||
class Download(
|
||||
@PrimaryKey override val id: String,
|
||||
@ColumnInfo(name = "playlist_id")
|
||||
var playlistId: String? = null
|
||||
|
||||
var playlistId: String? = null,
|
||||
@ColumnInfo(name = "playlist_name")
|
||||
var playlistName: String? = null
|
||||
|
||||
var playlistName: String? = null,
|
||||
@ColumnInfo(name = "download_state", defaultValue = "1")
|
||||
var downloadState: Int = 0
|
||||
|
||||
var downloadState: Int = 0,
|
||||
@ColumnInfo(name = "download_uri", defaultValue = "")
|
||||
var downloadUri: String? = null
|
||||
|
||||
var downloadUri: String? = null,
|
||||
) : Child(id) {
|
||||
constructor(child: Child) : this(child.id) {
|
||||
parentId = child.parentId
|
||||
isDir = child.isDir
|
||||
|
|
@ -40,6 +38,8 @@ class Download(@PrimaryKey override val id: String) : Child(id) {
|
|||
transcodedSuffix = child.transcodedSuffix
|
||||
duration = child.duration
|
||||
bitrate = child.bitrate
|
||||
samplingRate = child.samplingRate
|
||||
bitDepth = child.bitDepth
|
||||
path = child.path
|
||||
isVideo = child.isVideo
|
||||
userRating = child.userRating
|
||||
|
|
@ -60,5 +60,5 @@ class Download(@PrimaryKey override val id: String) : Child(id) {
|
|||
@Keep
|
||||
data class DownloadStack(
|
||||
var id: String,
|
||||
var view: String?
|
||||
var view: String?,
|
||||
)
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.repository;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import android.util.Log;
|
||||
|
||||
import com.cappielloantonio.tempo.App;
|
||||
import com.cappielloantonio.tempo.interfaces.DecadesCallback;
|
||||
|
|
@ -31,14 +32,22 @@ public class AlbumRepository {
|
|||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getAlbumList2() != null && response.body().getSubsonicResponse().getAlbumList2().getAlbums() != null) {
|
||||
if (response.isSuccessful()
|
||||
&& response.body() != null
|
||||
&& response.body().getSubsonicResponse().getAlbumList2() != null
|
||||
&& response.body().getSubsonicResponse().getAlbumList2().getAlbums() != null) {
|
||||
|
||||
listLiveAlbums.setValue(response.body().getSubsonicResponse().getAlbumList2().getAlbums());
|
||||
} else {
|
||||
Log.e("AlbumRepository", "API Error on getAlbums. HTTP Code: " + response.code());
|
||||
listLiveAlbums.setValue(new ArrayList<>());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
Log.e("AlbumRepository", "Network Failure on getAlbums: " + t.getMessage());
|
||||
listLiveAlbums.setValue(new ArrayList<>());
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
if (response.isSuccessful() && response.body() != null &&
|
||||
response.body().getSubsonicResponse().getArtist() != null &&
|
||||
response.body().getSubsonicResponse().getArtist().getAlbums() != null) {
|
||||
|
||||
List<AlbumID3> 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<ApiResponse> call, @NonNull Throwable t) {
|
||||
Log.d("ArtistSync", "Error getting artist info: " + t.getMessage());
|
||||
callback.onSongsCollected(new ArrayList<>());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void fetchAllAlbumSongsWithCallback(List<AlbumID3> albums, ArtistSongsCallback callback) {
|
||||
if (albums == null || albums.isEmpty()) {
|
||||
Log.d("ArtistSync", "No albums to process");
|
||||
callback.onSongsCollected(new ArrayList<>());
|
||||
return;
|
||||
}
|
||||
|
||||
List<Child> 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<List<Child>> 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<Child> songs);
|
||||
}
|
||||
|
||||
public MutableLiveData<List<ArtistID3>> getStarredArtists(boolean random, int size) {
|
||||
MutableLiveData<List<ArtistID3>> 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<ArtistID3> artists, MutableLiveData<List<ArtistID3>> list) {
|
||||
List<ArtistID3> liveArtists = list.getValue();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,20 @@ public class DownloadRepository {
|
|||
return downloadDao.getAll();
|
||||
}
|
||||
|
||||
public List<Download> 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<Download> downloads;
|
||||
|
||||
public GetAllDownloadsThreadSafe(DownloadDao downloadDao) {
|
||||
this.downloadDao = downloadDao;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
downloads = downloadDao.getAllSync();
|
||||
}
|
||||
|
||||
public List<Download> 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<String> 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<String> ids;
|
||||
|
||||
public DeleteMultipleThreadSafe(DownloadDao downloadDao, List<String> ids) {
|
||||
this.downloadDao = downloadDao;
|
||||
this.ids = ids;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
downloadDao.deleteByIds(ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<LyricsCache> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -80,21 +80,52 @@ public class PlaylistRepository {
|
|||
return listLivePlaylistSongs;
|
||||
}
|
||||
|
||||
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) {
|
||||
public MutableLiveData<Playlist> getPlaylist(String id) {
|
||||
MutableLiveData<Playlist> playlistLiveData = new MutableLiveData<>();
|
||||
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
.updatePlaylist(playlistId, null, true, songsId, null)
|
||||
.getPlaylist(id)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> 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<ApiResponse> 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<String> 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<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> 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<ApiResponse> 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<String> songsId) {
|
||||
|
|
@ -131,23 +162,6 @@ public class PlaylistRepository {
|
|||
});
|
||||
}
|
||||
|
||||
public void updatePlaylist(String playlistId, String name, boolean isPublic, ArrayList<String> songIdToAdd, ArrayList<Integer> songIndexToRemove) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
.updatePlaylist(playlistId, name, isPublic, songIdToAdd, songIndexToRemove)
|
||||
.enqueue(new Callback<ApiResponse>() {
|
||||
@Override
|
||||
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void deletePlaylist(String playlistId) {
|
||||
App.getSubsonicClientInstance(false)
|
||||
.getPlaylistClient()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MediaBrowser> attachedBrowserRef = new WeakReference<>(null);
|
||||
|
||||
public static void registerPlaybackObserver(
|
||||
ListenableFuture<MediaBrowser> browserFuture,
|
||||
PlaybackViewModel playbackViewModel
|
||||
) {
|
||||
if (browserFuture == null) return;
|
||||
|
||||
Futures.addCallback(browserFuture, new FutureCallback<MediaBrowser>() {
|
||||
@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<MediaBrowser> 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<MediaBrowser> 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<MediaBrowser> mediaBrowserListenableFuture, InternetRadioStation internetRadioStation) {
|
||||
if (mediaBrowserListenableFuture != null) {
|
||||
mediaBrowserListenableFuture.addListener(() -> {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ApiResponse> ping() {
|
||||
Log.d(TAG, "ping()");
|
||||
return systemService.ping(subsonic.getParams());
|
||||
Call<ApiResponse> 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<ApiResponse> getLicense() {
|
||||
|
|
|
|||
|
|
@ -4,38 +4,36 @@ import android.os.Parcelable
|
|||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class AlbumID3 : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
var artist: String? = null
|
||||
var artistId: String? = null
|
||||
open class AlbumID3(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
var artist: String? = null,
|
||||
var artistId: String? = null,
|
||||
@SerializedName("coverArt")
|
||||
var coverArtId: String? = null
|
||||
var songCount: Int? = 0
|
||||
var duration: Int? = 0
|
||||
var playCount: Long? = 0
|
||||
var created: Date? = null
|
||||
var starred: Date? = null
|
||||
var year: Int = 0
|
||||
var genre: String? = null
|
||||
var played: Date? = Date(0)
|
||||
var userRating: Int? = 0
|
||||
var recordLabels: List<RecordLabel>? = null
|
||||
var musicBrainzId: String? = null
|
||||
var genres: List<ItemGenre>? = null
|
||||
var artists: List<ArtistID3>? = null
|
||||
var displayArtist: String? = null
|
||||
var releaseTypes: List<String>? = null
|
||||
var moods: List<String>? = null
|
||||
var sortName: String? = null
|
||||
var originalReleaseDate: ItemDate? = null
|
||||
var releaseDate: ItemDate? = null
|
||||
var isCompilation: Boolean? = null
|
||||
var discTitles: List<DiscTitle>? = null
|
||||
}
|
||||
var coverArtId: String? = null,
|
||||
var songCount: Int? = 0,
|
||||
var duration: Int? = 0,
|
||||
var playCount: Long? = 0,
|
||||
var created: Date? = null,
|
||||
var starred: Date? = null,
|
||||
var year: Int = 0,
|
||||
var genre: String? = null,
|
||||
var played: Date? = Date(0),
|
||||
var userRating: Int? = 0,
|
||||
var recordLabels: List<RecordLabel>? = null,
|
||||
var musicBrainzId: String? = null,
|
||||
var genres: List<ItemGenre>? = null,
|
||||
var artists: List<ArtistID3>? = null,
|
||||
var displayArtist: String? = null,
|
||||
var releaseTypes: List<String>? = null,
|
||||
var moods: List<String>? = null,
|
||||
var sortName: String? = null,
|
||||
var originalReleaseDate: ItemDate? = null,
|
||||
var releaseDate: ItemDate? = null,
|
||||
var isCompilation: Boolean? = null,
|
||||
var discTitles: List<DiscTitle>? = null,
|
||||
) : Parcelable
|
||||
|
|
@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize
|
|||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class AlbumWithSongsID3 : AlbumID3(), Parcelable {
|
||||
class AlbumWithSongsID3(
|
||||
@SerializedName("song")
|
||||
var songs: List<Child>? = null
|
||||
}
|
||||
var songs: List<Child>? = null,
|
||||
) : AlbumID3(), Parcelable
|
||||
|
|
@ -7,10 +7,10 @@ import java.util.Date
|
|||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class Artist : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
var starred: Date? = null
|
||||
var userRating: Int? = null
|
||||
var averageRating: Double? = null
|
||||
}
|
||||
class Artist(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
var starred: Date? = null,
|
||||
var userRating: Int? = null,
|
||||
var averageRating: Double? = null,
|
||||
) : Parcelable
|
||||
|
|
@ -4,15 +4,15 @@ import android.os.Parcelable
|
|||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class ArtistID3 : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
open class ArtistID3(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
@SerializedName("coverArt")
|
||||
var coverArtId: String? = null
|
||||
var albumCount = 0
|
||||
var starred: Date? = null
|
||||
}
|
||||
var coverArtId: String? = null,
|
||||
var albumCount: Int = 0,
|
||||
var starred: Date? = null,
|
||||
) : Parcelable
|
||||
|
|
@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize
|
|||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class ArtistWithAlbumsID3 : ArtistID3(), Parcelable {
|
||||
class ArtistWithAlbumsID3(
|
||||
@SerializedName("album")
|
||||
var albums: List<AlbumID3>? = null
|
||||
}
|
||||
var albums: List<AlbumID3>? = null,
|
||||
) : ArtistID3(), Parcelable
|
||||
|
|
@ -8,15 +8,15 @@ import java.util.Date
|
|||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class Directory : Parcelable {
|
||||
class Directory(
|
||||
@SerializedName("child")
|
||||
var children: List<Child>? = null
|
||||
var id: String? = null
|
||||
var children: List<Child>? = null,
|
||||
var id: String? = null,
|
||||
@SerializedName("parent")
|
||||
var parentId: String? = null
|
||||
var name: String? = null
|
||||
var starred: Date? = null
|
||||
var userRating: Int? = null
|
||||
var averageRating: Double? = null
|
||||
var playCount: Long? = null
|
||||
}
|
||||
var parentId: String? = null,
|
||||
var name: String? = null,
|
||||
var starred: Date? = null,
|
||||
var userRating: Int? = null,
|
||||
var averageRating: Double? = null,
|
||||
var playCount: Long? = null,
|
||||
) : Parcelable
|
||||
|
|
@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
|
|||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class DiscTitle : Parcelable {
|
||||
var disc: Int? = null
|
||||
var title: String? = null
|
||||
}
|
||||
open class DiscTitle(
|
||||
var disc: Int? = null,
|
||||
var title: String? = null,
|
||||
) : Parcelable
|
||||
|
|
@ -7,9 +7,9 @@ import kotlinx.parcelize.Parcelize
|
|||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class Genre : Parcelable {
|
||||
class Genre(
|
||||
@SerializedName("value")
|
||||
var genre: String? = null
|
||||
var songCount = 0
|
||||
var albumCount = 0
|
||||
}
|
||||
var genre: String? = null,
|
||||
var songCount: Int = 0,
|
||||
var albumCount: Int = 0,
|
||||
) : Parcelable
|
||||
|
|
@ -6,9 +6,9 @@ import kotlinx.parcelize.Parcelize
|
|||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class InternetRadioStation : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
var streamUrl: String? = null
|
||||
var homePageUrl: String? = null
|
||||
}
|
||||
class InternetRadioStation(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
var streamUrl: String? = null,
|
||||
var homePageUrl: String? = null,
|
||||
) : Parcelable
|
||||
|
|
@ -9,11 +9,11 @@ import java.util.Locale
|
|||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class ItemDate : Parcelable {
|
||||
var year: Int? = null
|
||||
var month: Int? = null
|
||||
var day: Int? = null
|
||||
|
||||
open class ItemDate(
|
||||
var year: Int? = null,
|
||||
var month: Int? = null,
|
||||
var day: Int? = null,
|
||||
) : Parcelable {
|
||||
fun getFormattedDate(): String? {
|
||||
if (year == null && month == null && day == null) return null
|
||||
|
||||
|
|
@ -22,8 +22,7 @@ open class ItemDate : Parcelable {
|
|||
SimpleDateFormat("yyyy", Locale.getDefault())
|
||||
} else if (day == null) {
|
||||
SimpleDateFormat("MMMM yyyy", Locale.getDefault())
|
||||
}
|
||||
else{
|
||||
} else {
|
||||
SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,6 @@ import kotlinx.parcelize.Parcelize
|
|||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class ItemGenre : Parcelable {
|
||||
var name: String? = null
|
||||
}
|
||||
open class ItemGenre(
|
||||
var name: String? = null,
|
||||
) : Parcelable
|
||||
|
|
@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
|
|||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class MusicFolder : Parcelable {
|
||||
var id: String? = null
|
||||
var name: String? = null
|
||||
}
|
||||
class MusicFolder(
|
||||
var id: String? = null,
|
||||
var name: String? = null,
|
||||
) : Parcelable
|
||||
|
|
@ -8,10 +8,9 @@ import kotlinx.parcelize.Parcelize
|
|||
@Parcelize
|
||||
class NowPlayingEntry(
|
||||
@SerializedName("_id")
|
||||
override val id: String
|
||||
) : Child(id) {
|
||||
var username: String? = null
|
||||
var minutesAgo = 0
|
||||
var playerId = 0
|
||||
var playerName: String? = null
|
||||
}
|
||||
override val id: String,
|
||||
var username: String? = null,
|
||||
var minutesAgo: Int = 0,
|
||||
var playerId: Int = 0,
|
||||
var playerName: String? = null,
|
||||
) : Child(id)
|
||||
|
|
@ -7,8 +7,9 @@ import androidx.room.Entity
|
|||
import androidx.room.Ignore
|
||||
import androidx.room.PrimaryKey
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
|
|
@ -16,27 +17,56 @@ import java.util.*
|
|||
open class Playlist(
|
||||
@PrimaryKey
|
||||
@ColumnInfo(name = "id")
|
||||
open var id: String
|
||||
) : Parcelable {
|
||||
open var id: String,
|
||||
@ColumnInfo(name = "name")
|
||||
var name: String? = null
|
||||
var name: String? = null,
|
||||
@ColumnInfo(name = "duration")
|
||||
var duration: Long = 0,
|
||||
@ColumnInfo(name = "coverArt")
|
||||
var coverArtId: String? = null,
|
||||
) : Parcelable {
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var comment: String? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var owner: String? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
@SerializedName("public")
|
||||
var isUniversal: Boolean? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var songCount: Int = 0
|
||||
@ColumnInfo(name = "duration")
|
||||
var duration: Long = 0
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var created: Date? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var changed: Date? = null
|
||||
@ColumnInfo(name = "coverArt")
|
||||
var coverArtId: String? = null
|
||||
@Ignore
|
||||
@IgnoredOnParcel
|
||||
var allowedUsers: List<String>? = null
|
||||
@Ignore
|
||||
constructor(
|
||||
id: String,
|
||||
name: String?,
|
||||
comment: String?,
|
||||
owner: String?,
|
||||
isUniversal: Boolean?,
|
||||
songCount: Int,
|
||||
duration: Long,
|
||||
created: Date?,
|
||||
changed: Date?,
|
||||
coverArtId: String?,
|
||||
allowedUsers: List<String>?,
|
||||
) : this(id, name, duration, coverArtId) {
|
||||
this.comment = comment
|
||||
this.owner = owner
|
||||
this.isUniversal = isUniversal
|
||||
this.songCount = songCount
|
||||
this.created = created
|
||||
this.changed = changed
|
||||
this.allowedUsers = allowedUsers
|
||||
}
|
||||
}
|
||||
|
|
@ -9,8 +9,7 @@ import kotlinx.parcelize.Parcelize
|
|||
@Parcelize
|
||||
class PlaylistWithSongs(
|
||||
@SerializedName("_id")
|
||||
override var id: String
|
||||
) : Playlist(id), Parcelable {
|
||||
override var id: String,
|
||||
@SerializedName("entry")
|
||||
var entries: List<Child>? = null
|
||||
}
|
||||
var entries: List<Child>? = null,
|
||||
) : Playlist(id), Parcelable
|
||||
|
|
@ -6,5 +6,5 @@ import com.google.gson.annotations.SerializedName
|
|||
@Keep
|
||||
class Playlists(
|
||||
@SerializedName("playlist")
|
||||
var playlists: List<Playlist>? = null
|
||||
var playlists: List<Playlist>? = null,
|
||||
)
|
||||
|
|
@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models
|
|||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class PodcastChannel : Parcelable {
|
||||
class PodcastChannel(
|
||||
@SerializedName("episode")
|
||||
var episodes: List<PodcastEpisode>? = null
|
||||
var id: String? = null
|
||||
var url: String? = null
|
||||
var title: String? = null
|
||||
var description: String? = null
|
||||
var episodes: List<PodcastEpisode>? = null,
|
||||
var id: String? = null,
|
||||
var url: String? = null,
|
||||
var title: String? = null,
|
||||
var description: String? = null,
|
||||
@SerializedName("coverArt")
|
||||
var coverArtId: String? = null
|
||||
var originalImageUrl: String? = null
|
||||
var status: String? = null
|
||||
var errorMessage: String? = null
|
||||
}
|
||||
var coverArtId: String? = null,
|
||||
var originalImageUrl: String? = null,
|
||||
var status: String? = null,
|
||||
var errorMessage: String? = null,
|
||||
) : Parcelable
|
||||
|
|
@ -3,37 +3,38 @@ package com.cappielloantonio.tempo.subsonic.models
|
|||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class PodcastEpisode : Parcelable {
|
||||
var id: String? = null
|
||||
class PodcastEpisode(
|
||||
var id: String? = null,
|
||||
@SerializedName("parent")
|
||||
var parentId: String? = null
|
||||
var isDir = false
|
||||
var title: String? = null
|
||||
var album: String? = null
|
||||
var artist: String? = null
|
||||
var year: Int? = null
|
||||
var genre: String? = null
|
||||
var parentId: String? = null,
|
||||
var isDir: Boolean = false,
|
||||
var title: String? = null,
|
||||
var album: String? = null,
|
||||
var artist: String? = null,
|
||||
var year: Int? = null,
|
||||
var genre: String? = null,
|
||||
@SerializedName("coverArt")
|
||||
var coverArtId: String? = null
|
||||
var size: Long? = null
|
||||
var contentType: String? = null
|
||||
var suffix: String? = null
|
||||
var duration: Int? = null
|
||||
var coverArtId: String? = null,
|
||||
var size: Long? = null,
|
||||
var contentType: String? = null,
|
||||
var suffix: String? = null,
|
||||
var duration: Int? = null,
|
||||
@SerializedName("bitRate")
|
||||
var bitrate: Int? = null
|
||||
var path: String? = null
|
||||
var isVideo: Boolean = false
|
||||
var created: Date? = null
|
||||
var artistId: String? = null
|
||||
var type: String? = null
|
||||
var streamId: String? = null
|
||||
var channelId: String? = null
|
||||
var description: String? = null
|
||||
var status: String? = null
|
||||
var publishDate: Date? = null
|
||||
}
|
||||
var bitrate: Int? = null,
|
||||
var path: String? = null,
|
||||
var isVideo: Boolean = false,
|
||||
var created: Date? = null,
|
||||
var artistId: String? = null,
|
||||
var type: String? = null,
|
||||
var streamId: String? = null,
|
||||
var channelId: String? = null,
|
||||
var description: String? = null,
|
||||
var status: String? = null,
|
||||
var publishDate: Date? = null,
|
||||
) : Parcelable
|
||||
|
|
@ -2,10 +2,11 @@ package com.cappielloantonio.tempo.subsonic.models
|
|||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
open class RecordLabel : Parcelable {
|
||||
var name: String? = null
|
||||
}
|
||||
open class RecordLabel(
|
||||
var name: String? = null,
|
||||
) : Parcelable
|
||||
|
|
@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models
|
|||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
|
||||
@Keep
|
||||
@Parcelize
|
||||
class Share : Parcelable {
|
||||
data class Share(
|
||||
@SerializedName("entry")
|
||||
var entries: List<Child>? = null
|
||||
var id: String? = null
|
||||
var url: String? = null
|
||||
var description: String? = null
|
||||
var username: String? = null
|
||||
var created: Date? = null
|
||||
var expires: Date? = null
|
||||
var lastVisited: Date? = null
|
||||
var visitCount = 0
|
||||
}
|
||||
var entries: List<Child>? = null,
|
||||
var id: String? = null,
|
||||
var url: String? = null,
|
||||
var description: String? = null,
|
||||
var username: String? = null,
|
||||
var created: Date? = null,
|
||||
var expires: Date? = null,
|
||||
var lastVisited: Date? = null,
|
||||
var visitCount: Int = 0
|
||||
) : Parcelable
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.cappielloantonio.tempo.subsonic.utils
|
||||
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParseException
|
||||
import java.lang.reflect.Type
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
// This adapter handles Date objects, returning null if the JSON string is empty or unparsable.
|
||||
class EmptyDateTypeAdapter : JsonDeserializer<Date> {
|
||||
|
||||
// Define the date formats expected from the Subsonic server.
|
||||
private val dateFormats: List<SimpleDateFormat> = listOf(
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") },
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") },
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||
)
|
||||
|
||||
@Throws(JsonParseException::class)
|
||||
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Date? {
|
||||
val jsonString = json.asString.trim()
|
||||
|
||||
if (jsonString.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (format in dateFormats) {
|
||||
try {
|
||||
return format.parse(jsonString)
|
||||
} catch (e: ParseException) {
|
||||
// Ignore and try the next format
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import com.cappielloantonio.tempo.util.MusicUtil;
|
|||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAdapter.ViewHolder> implements Filterable {
|
||||
|
|
@ -152,12 +153,20 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAd
|
|||
}
|
||||
|
||||
public void sort(String order) {
|
||||
if (albums == null) return;
|
||||
|
||||
switch (order) {
|
||||
case Constants.ALBUM_ORDER_BY_NAME:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getName));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getName() != null ? album.getName() : "",
|
||||
String.CASE_INSENSITIVE_ORDER
|
||||
));
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_ARTIST:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getArtist, Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getArtist() != null ? album.getArtist() : "",
|
||||
String.CASE_INSENSITIVE_ORDER
|
||||
));
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_YEAR:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getYear));
|
||||
|
|
@ -166,15 +175,23 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAd
|
|||
Collections.shuffle(albums);
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_RECENTLY_ADDED:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getCreated));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getCreated() != null ? album.getCreated() : new Date(0),
|
||||
Comparator.nullsLast(Date::compareTo)
|
||||
));
|
||||
Collections.reverse(albums);
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getPlayed));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getPlayed() != null ? album.getPlayed() : new Date(0),
|
||||
Comparator.nullsLast(Date::compareTo)
|
||||
));
|
||||
Collections.reverse(albums);
|
||||
break;
|
||||
case Constants.ALBUM_ORDER_BY_MOST_PLAYED:
|
||||
albums.sort(Comparator.comparing(AlbumID3::getPlayCount));
|
||||
albums.sort(Comparator.comparing(
|
||||
album -> album.getPlayCount() != null ? album.getPlayCount() : 0L
|
||||
));
|
||||
Collections.reverse(albums);
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ public class DownloadHorizontalAdapter extends RecyclerView.Adapter<DownloadHori
|
|||
R.string.song_subtitle_formatter,
|
||||
song.getArtist(),
|
||||
MusicUtil.getReadableDurationString(song.getDuration(), false),
|
||||
""
|
||||
MusicUtil.getReadableAudioQualityString(song)
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.adapter;
|
|||
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
|
@ -23,17 +24,24 @@ import com.cappielloantonio.tempo.util.Constants;
|
|||
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueueAdapter.ViewHolder> {
|
||||
private static final String TAG = "PlayerSongQueueAdapter";
|
||||
private final ClickCallback click;
|
||||
|
||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||
private List<Child> songs;
|
||||
|
||||
private String currentPlayingId;
|
||||
private boolean isPlaying;
|
||||
private List<Integer> currentPlayingPositions = Collections.emptyList();
|
||||
|
||||
public PlayerSongQueueAdapter(ClickCallback click) {
|
||||
this.click = click;
|
||||
this.songs = Collections.emptyList();
|
||||
|
|
@ -104,6 +112,46 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
|
|||
} else {
|
||||
holder.item.ratingIndicatorImageView.setVisibility(View.GONE);
|
||||
}
|
||||
holder.itemView.setOnClickListener(v -> {
|
||||
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<Child> getItems() {
|
||||
|
|
@ -132,6 +180,46 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
|
|||
this.mediaBrowserListenableFuture = mediaBrowserListenableFuture;
|
||||
}
|
||||
|
||||
public void setPlaybackState(String mediaId, boolean playing) {
|
||||
String oldId = this.currentPlayingId;
|
||||
boolean oldPlaying = this.isPlaying;
|
||||
List<Integer> oldPositions = currentPlayingPositions;
|
||||
|
||||
this.currentPlayingId = mediaId;
|
||||
this.isPlaying = playing;
|
||||
|
||||
if (Objects.equals(oldId, mediaId) && oldPlaying == playing) {
|
||||
List<Integer> 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<Integer> findPositionsById(String id) {
|
||||
if (id == null) return Collections.emptyList();
|
||||
List<Integer> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SongHorizontalAdapter.ViewHolder> implements Filterable {
|
||||
|
|
@ -42,6 +50,11 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
|||
private List<Child> songs;
|
||||
private String currentFilter;
|
||||
|
||||
private String currentPlayingId;
|
||||
private boolean isPlaying;
|
||||
private List<Integer> currentPlayingPositions = Collections.emptyList();
|
||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||
|
||||
private final Filter filtering = new Filter() {
|
||||
@Override
|
||||
protected FilterResults performFiltering(CharSequence constraint) {
|
||||
|
|
@ -70,10 +83,16 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
|||
protected void publishResults(CharSequence constraint, FilterResults results) {
|
||||
songs = (List<Child>) 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<SongHorizontalAd
|
|||
this.songsFull = Collections.emptyList();
|
||||
this.currentFilter = "";
|
||||
this.album = album;
|
||||
setHasStableIds(false);
|
||||
|
||||
if (lifecycleOwner != null) {
|
||||
MappingUtil.observeExternalAudioRefresh(lifecycleOwner, this::handleExternalAudioRefresh);
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
|
|
@ -91,7 +115,16 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(ViewHolder holder, int position) {
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position, @NonNull List<Object> 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<SongHorizontalAd
|
|||
|
||||
holder.item.trackNumberTextView.setText(MusicUtil.getReadableTrackNumber(holder.itemView.getContext(), song.getTrack()));
|
||||
|
||||
if (DownloadUtil.getDownloadTracker(holder.itemView.getContext()).isDownloaded(song.getId())) {
|
||||
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.VISIBLE);
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
if (DownloadUtil.getDownloadTracker(holder.itemView.getContext()).isDownloaded(song.getId())) {
|
||||
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.GONE);
|
||||
if (ExternalAudioReader.getUri(song) != null) {
|
||||
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
if (showCoverArt) CustomGlideRequest.Builder
|
||||
|
|
@ -165,6 +206,39 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
|||
} else {
|
||||
holder.item.ratingIndicatorImageView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
bindPlaybackState(holder, song);
|
||||
}
|
||||
|
||||
private void handleExternalAudioRefresh() {
|
||||
if (Preferences.getDownloadDirectoryUri() != null) {
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void bindPlaybackState(@NonNull 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);
|
||||
}
|
||||
if (!showCoverArt) {
|
||||
holder.item.trackNumberTextView.setVisibility(View.INVISIBLE);
|
||||
} else {
|
||||
holder.item.coverArtOverlay.setVisibility(View.VISIBLE);
|
||||
}
|
||||
} else {
|
||||
holder.item.playPauseIcon.setVisibility(View.INVISIBLE);
|
||||
if (!showCoverArt) {
|
||||
holder.item.trackNumberTextView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
holder.item.coverArtOverlay.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -188,6 +262,46 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
|||
return position;
|
||||
}
|
||||
|
||||
public void setPlaybackState(String mediaId, boolean playing) {
|
||||
String oldId = this.currentPlayingId;
|
||||
boolean oldPlaying = this.isPlaying;
|
||||
List<Integer> oldPositions = currentPlayingPositions;
|
||||
|
||||
this.currentPlayingId = mediaId;
|
||||
this.isPlaying = playing;
|
||||
|
||||
if (Objects.equals(oldId, mediaId) && oldPlaying == playing) {
|
||||
List<Integer> 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<Integer> findPositionsById(String id) {
|
||||
if (id == null) return Collections.emptyList();
|
||||
List<Integer> 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<SongHorizontalAd
|
|||
}
|
||||
|
||||
public void onClick() {
|
||||
int pos = getBindingAdapterPosition();
|
||||
Child tappedSong = songs.get(pos);
|
||||
|
||||
Bundle bundle = new Bundle();
|
||||
bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(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<SongHorizontalAd
|
|||
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void setMediaBrowserListenableFuture(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture) {
|
||||
this.mediaBrowserListenableFuture = mediaBrowserListenableFuture;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Intent> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MediaBrowser> 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);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Child> 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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Child> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SeekBar>()
|
||||
|
||||
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<LinearLayout>(R.id.equalizer_not_supported_container)
|
||||
val switchRow = view?.findViewById<View>(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()
|
||||
|
|
@ -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<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> songs) {
|
||||
|
|
@ -318,32 +332,12 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
|||
|
||||
private void initSyncStarredAlbumsView() {
|
||||
if (Preferences.isStarredAlbumsSyncEnabled()) {
|
||||
homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observeForever(new Observer<List<AlbumID3>>() {
|
||||
homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer<List<AlbumID3>>() {
|
||||
@Override
|
||||
public void onChanged(List<AlbumID3> albums) {
|
||||
if (albums != null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
List<String> 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<List<Child>>() {
|
||||
homeViewModel.getAllStarredAlbumSongs().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> 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++;
|
||||
}
|
||||
}
|
||||
|
||||
if (songsToDownload > 0) {
|
||||
Toast.makeText(requireContext(),
|
||||
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
|
||||
Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
homeViewModel.getAllStarredAlbumSongs().removeObserver(this);
|
||||
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void checkIfAlbumsNeedSync(List<AlbumID3> albums) {
|
||||
homeViewModel.getAllStarredAlbumSongs().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> allSongs) {
|
||||
if (allSongs != null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
int songsToDownload = 0;
|
||||
List<String> albumsNeedingSync = new ArrayList<>();
|
||||
|
||||
for (AlbumID3 album : albums) {
|
||||
boolean albumNeedsSync = false;
|
||||
// Check if any songs from this album need downloading
|
||||
for (Child song : allSongs) {
|
||||
if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) &&
|
||||
!manager.isDownloaded(song.getId())) {
|
||||
songsToDownload++;
|
||||
albumNeedsSync = true;
|
||||
}
|
||||
}
|
||||
if (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<List<ArtistID3>>() {
|
||||
@Override
|
||||
public void onChanged(List<ArtistID3> 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<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> allSongs) {
|
||||
if (allSongs != null && !allSongs.isEmpty()) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
int songsToDownload = 0;
|
||||
|
||||
for (Child song : allSongs) {
|
||||
if (!manager.isDownloaded(song.getId())) {
|
||||
manager.download(MappingUtil.mapDownload(song), new Download(song));
|
||||
songsToDownload++;
|
||||
}
|
||||
}
|
||||
|
||||
if (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<ArtistID3> artists) {
|
||||
homeViewModel.getAllStarredArtistSongs().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
|
||||
@Override
|
||||
public void onChanged(List<Child> allSongs) {
|
||||
if (allSongs != null) {
|
||||
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
|
||||
int songsToDownload = 0;
|
||||
List<String> artistsNeedingSync = new ArrayList<>();
|
||||
|
||||
for (ArtistID3 artist : artists) {
|
||||
boolean artistNeedsSync = false;
|
||||
// Check if any songs from this artist need downloading
|
||||
for (Child song : allSongs) {
|
||||
if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) &&
|
||||
!manager.isDownloaded(song.getId())) {
|
||||
songsToDownload++;
|
||||
artistNeedsSync = true;
|
||||
}
|
||||
}
|
||||
if (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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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<MediaBrowser> 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
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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<Line> lines = lyricsList.getStructuredLyrics().get(0).getLine();
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MediaBrowser> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Intent> someActivityResultLauncher;
|
||||
private ActivityResultLauncher<Intent> equalizerResultLauncher;
|
||||
private ActivityResultLauncher<Intent> 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();
|
||||
}
|
||||
});
|
||||
|
|
@ -282,6 +362,20 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
|||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Child> currentAlbumTracks = Collections.emptyList();
|
||||
private List<MediaItem> currentAlbumMediaItems = Collections.emptyList();
|
||||
|
||||
private ListenableFuture<MediaBrowser> 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<Download> 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<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
|
||||
List<Download> 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<Download> 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<MediaItem> 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<MediaItem> 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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
|
||||
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
|
||||
|
||||
DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
|
||||
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
|
||||
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
|
||||
DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
|
||||
} else {
|
||||
songs.forEach(ExternalAudioReader::delete);
|
||||
}
|
||||
|
||||
dismissBottomSheet();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<MediaBrowser> 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() {
|
||||
|
|
|
|||
|
|
@ -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<Child> liveData = songRepository.getSong(id);
|
||||
Observer<Child> observer = new Observer<Child>() {
|
||||
@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<AlbumID3> liveData = albumRepository.getAlbum(id);
|
||||
Observer<AlbumID3> observer = new Observer<AlbumID3>() {
|
||||
@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<ArtistID3> liveData = artistRepository.getArtist(id);
|
||||
Observer<ArtistID3> observer = new Observer<ArtistID3>() {
|
||||
@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<Playlist> liveData = playlistRepository.getPlaylist(id);
|
||||
Observer<Playlist> observer = new Observer<Playlist>() {
|
||||
@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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, DocumentFile> cache = new ConcurrentHashMap<>();
|
||||
private static final Object LOCK = new Object();
|
||||
private static final ExecutorService REFRESH_EXECUTOR = Executors.newSingleThreadExecutor();
|
||||
private static final MutableLiveData<Long> 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<Long> 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<String, Long> expectedSizes = ExternalDownloadMetadataStore.snapshot();
|
||||
Set<String> verifiedKeys = new HashSet<>();
|
||||
Map<String, DocumentFile> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, Long> snapshot() {
|
||||
JSONObject object = readAll();
|
||||
if (object.length() == 0) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<String, Long> sizes = new HashMap<>();
|
||||
Iterator<String> 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<String> keysToKeep) {
|
||||
if (keysToKeep == null || keysToKeep.isEmpty()) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
JSONObject object = readAll();
|
||||
if (object.length() == 0) {
|
||||
return;
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
Iterator<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Child> 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");
|
||||
}
|
||||
}
|
||||
///
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<List<Child>> downloadedTrackSample = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<ArrayList<DownloadStack>> viewStack = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<Integer> refreshResult = new MutableLiveData<>();
|
||||
|
||||
public DownloadViewModel(@NonNull Application application) {
|
||||
super(application);
|
||||
|
|
@ -43,6 +48,10 @@ public class DownloadViewModel extends AndroidViewModel {
|
|||
return viewStack;
|
||||
}
|
||||
|
||||
public LiveData<Integer> getRefreshResult() {
|
||||
return refreshResult;
|
||||
}
|
||||
|
||||
public void initViewStack(DownloadStack level) {
|
||||
ArrayList<DownloadStack> 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<Download> downloads = downloadRepository.getAllDownloads();
|
||||
if (downloads == null || downloads.isEmpty()) {
|
||||
refreshResult.postValue(0);
|
||||
return;
|
||||
}
|
||||
|
||||
ArrayList<Download> 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<String> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ public class HomeViewModel extends AndroidViewModel {
|
|||
private final SharingRepository sharingRepository;
|
||||
|
||||
private final StarredAlbumsSyncViewModel albumsSyncViewModel;
|
||||
private final StarredArtistsSyncViewModel artistSyncViewModel;
|
||||
|
||||
private final MutableLiveData<List<Child>> dicoverSongSample = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<List<AlbumID3>> 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<List<Child>> getAllStarredArtistSongs() {
|
||||
return artistSyncViewModel.getAllStarredArtistSongs();
|
||||
}
|
||||
|
||||
public LiveData<List<ArtistID3>> getStarredArtists(LifecycleOwner owner) {
|
||||
if (starredArtists.getValue() == null) {
|
||||
artistRepository.getStarredArtists(true, 20).observe(owner, starredArtists::postValue);
|
||||
|
|
|
|||
|
|
@ -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<String> currentSongId = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<Boolean> isPlaying = new MutableLiveData<>(false);
|
||||
|
||||
public LiveData<String> getCurrentSongId() {
|
||||
return currentSongId;
|
||||
}
|
||||
|
||||
public LiveData<Boolean> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> lyricsLiveData = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<LyricsList> lyricsListLiveData = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<Boolean> lyricsCachedLiveData = new MutableLiveData<>(false);
|
||||
private final MutableLiveData<String> descriptionLiveData = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<Child> liveMedia = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<AlbumID3> liveAlbum = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<ArtistID3> liveArtist = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<List<Child>> instantMix = new MutableLiveData<>(null);
|
||||
private final Gson gson = new Gson();
|
||||
private boolean lyricsSyncState = true;
|
||||
private LiveData<LyricsCache> cachedLyricsSource;
|
||||
private String currentSongId;
|
||||
private final Observer<LyricsCache> 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<List<Queue>> 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<Boolean> getLyricsCachedState() {
|
||||
return lyricsCachedLiveData;
|
||||
}
|
||||
|
||||
public void changeSyncLyricsState() {
|
||||
lyricsSyncState = !lyricsSyncState;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> 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<String> playlistSongIds = Lists.transform(playlistSongs, Child::getId);
|
||||
songIds.removeAll(playlistSongIds);
|
||||
}
|
||||
playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds));
|
||||
dialog.dismiss();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void setSongsToAdd(ArrayList<Child> songs) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<List<ArtistID3>> starredArtists = new MutableLiveData<>(null);
|
||||
private final MutableLiveData<List<Child>> starredArtistSongs = new MutableLiveData<>(null);
|
||||
|
||||
public StarredArtistsSyncViewModel(@NonNull Application application) {
|
||||
super(application);
|
||||
artistRepository = new ArtistRepository();
|
||||
}
|
||||
|
||||
public LiveData<List<ArtistID3>> getStarredArtists(LifecycleOwner owner) {
|
||||
artistRepository.getStarredArtists(false, -1).observe(owner, starredArtists::postValue);
|
||||
return starredArtists;
|
||||
}
|
||||
|
||||
public LiveData<List<Child>> getAllStarredArtistSongs() {
|
||||
artistRepository.getStarredArtists(false, -1).observeForever(new Observer<List<ArtistID3>>() {
|
||||
@Override
|
||||
public void onChanged(List<ArtistID3> 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<List<Child>> 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<ArtistID3> artists, ArtistSongsCallback callback) {
|
||||
if (artists == null || artists.isEmpty()) {
|
||||
callback.onSongsCollected(new ArrayList<>());
|
||||
return;
|
||||
}
|
||||
|
||||
List<Child> 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<Child> songs) {
|
||||
if (songs != null) {
|
||||
allSongs.addAll(songs);
|
||||
}
|
||||
|
||||
int remaining = remainingArtists.decrementAndGet();
|
||||
if (remaining == 0) {
|
||||
callback.onSongsCollected(allSongs);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private interface ArtistSongsCallback {
|
||||
void onSongsCollected(List<Child> songs);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MediaController> 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());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue