Compare commits

..

No commits in common. "3086a8b9f9148537b7f8a5c8f3e135228db19a2a" and "97d1b408e193e09ec1c545ceb1be711578a3ab97" have entirely different histories.

177 changed files with 2671 additions and 10806 deletions

View file

@ -1,160 +1,6 @@
# Changelog # Changelog
## What's Changed ## Pending release...
## [4.12.4](https://github.com/eddyizm/tempo/releases/tag/v4.12.4) (2026-03-01)
* feat: advertise existing long press to refresh per section on library page by @tvillega in https://github.com/eddyizm/tempus/pull/467
* fix: playlist filter returns properly filtered list and reset correctly by @eddyizm in https://github.com/eddyizm/tempus/pull/476
* feat: toggle player bitrate visibility on touch by @tvillega in https://github.com/eddyizm/tempus/pull/466
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.12.0...v4.12.3
## What's Changed
## [4.12.0](https://github.com/eddyizm/tempo/releases/tag/v4.12.0) (2026-02-28)
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/441
* feat: radio logos support for AndroidAuto by @dmachard in https://github.com/eddyizm/tempus/pull/435
* feat: Port remove song of playlist from tempus ng by @tvillega in https://github.com/eddyizm/tempus/pull/457
* fix: artist sort by name case sensitive by @tvillega in https://github.com/eddyizm/tempus/pull/462
* feat: added slide out enhanced navigation for tab mode and optionally portrait mode by @tvillega in https://github.com/eddyizm/tempus/pull/450
* feat: Android Auto: improve media service browsing by @MaFo-28 in https://github.com/eddyizm/tempus/pull/437
* feat: Support specifying a client certificate for mTLS auth by @tinsukE in https://github.com/eddyizm/tempus/pull/458
## New Contributors
* @MaFo-28 made their first contribution in https://github.com/eddyizm/tempus/pull/437
* @tinsukE made their first contribution in https://github.com/eddyizm/tempus/pull/458
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.11.0...v4.12.0
## What's Changed
## [4.11.0](https://github.com/eddyizm/tempo/releases/tag/v4.11.0) (2026-02-15)
* fix: added dynamic application id from gradle variant by @eddyizm in https://github.com/eddyizm/tempus/pull/425
* fix: Use Bluetooth tethering connection by @jaime-grj in https://github.com/eddyizm/tempus/pull/428
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/427
* fix: visual glitches on landscape navbar by @tvillega in https://github.com/eddyizm/tempus/pull/429
* fix: radio playback "source error" on android auto by @dmachard in https://github.com/eddyizm/tempus/pull/426
* fix: speed button overlaps with shuffle on landscape by @tvillega in https://github.com/eddyizm/tempus/pull/430
* fix: local url used in share link instead of server url by @tvillega in https://github.com/eddyizm/tempus/pull/431
* Feat :prefer downloaded files by @eddyizm in https://github.com/eddyizm/tempus/pull/433
* fix: radio metadata displayed by @TrackArcher in https://github.com/eddyizm/tempus/pull/352
* feat: improve playlist chooser dialog UI by @tvillega in https://github.com/eddyizm/tempus/pull/439
## New Contributors
* @dmachard made their first contribution in https://github.com/eddyizm/tempus/pull/426
* @TrackArcher made their first contribution in https://github.com/eddyizm/tempus/pull/352
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.10.1...v4.11.0
## What's Changed
## [4.10.1](https://github.com/eddyizm/tempo/releases/tag/v4.10.1) (2026-02-08)
* fix: Addressing some UI/UX quirks by @tiltshiftfocus in https://github.com/eddyizm/tempus/pull/413
* fix: keep observer until data is received on continuousPlay bug by @eddyizm in https://github.com/eddyizm/tempus/pull/421
* fix: album art now displays on android auto by @trobinson in https://github.com/eddyizm/tempus/pull/414
* feat: improve landscape view and increase items per row on landscape view by @tvillega in https://github.com/eddyizm/tempus/pull/411
## New Contributors
* @tiltshiftfocus made their first contribution in https://github.com/eddyizm/tempus/pull/413
* @trobinson made their first contribution in https://github.com/eddyizm/tempus/pull/414
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.8...v4.10.1
## What's Changed
## [4.9.8](https://github.com/eddyizm/tempo/releases/tag/v4.9.8) (2026-02-02)
* fix: missing Replay Gain metadata from .m4a files by @pgrit in https://github.com/eddyizm/tempus/pull/396
* fix: Improve Synced Lyrics by @pgrit in https://github.com/eddyizm/tempus/pull/384
* fix: Add selector for playlist visibility by @tvillega in https://github.com/eddyizm/tempus/pull/394
* chore(i18n): set links as untranslatable by @tvillega in https://github.com/eddyizm/tempus/pull/400
## New Contributors
* @tvillega made their first contribution in https://github.com/eddyizm/tempus/pull/394
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.5...v4.5.8
## What's Changed
## [4.9.5](https://github.com/eddyizm/tempo/releases/tag/v4.9.5) (2026-01-26)
* fix: Avoid crash when server has no songs by @jaime-grj in https://github.com/eddyizm/tempus/pull/389
* fix: updated dialog import to address crashing on android 15 by @eddyizm in https://github.com/eddyizm/tempus/pull/392
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.3...v4.9.5
## What's Changed
## [4.9.3](https://github.com/eddyizm/tempo/releases/tag/v4.9.3) (2026-01-25)
* fix: Proper raw stream detection by @jaime-grj in https://github.com/eddyizm/tempus/pull/382
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/381
* feat: add configurable timeout by @eddyizm in https://github.com/eddyizm/tempus/pull/386
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.1...v4.9.3
## What's Changed
## [4.9.1](https://github.com/eddyizm/tempo/releases/tag/v4.9.1) (2026-01-24)
* chore: i18n: Add Romanian translation (including locale_config this time!) by @DevMatei in https://github.com/eddyizm/tempus/pull/357
* French localization update by @benoit-smith in https://github.com/eddyizm/tempus/pull/356
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/364
* docs: updated readme and added known issues for airsonic work around by @eddyizm in https://github.com/eddyizm/tempus/pull/366
* fix: toast for made for you click indication by @eddyizm in https://github.com/eddyizm/tempus/pull/365
* fix: sort playlist view by @eddyizm in https://github.com/eddyizm/tempus/pull/368
* feat: sort preference for playlists by @eddyizm in https://github.com/eddyizm/tempus/pull/370
* fix: use existing future when adding tracks, dialed random album tracks off in instant mix by @eddyizm in https://github.com/eddyizm/tempus/pull/373
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/374
* fix: Check for OpenSubsonic extensions also with password authentication by @pgrit in https://github.com/eddyizm/tempus/pull/375
* feat: Implement duration and seeking for transcodes by @drakeerv in https://github.com/eddyizm/tempus/pull/358
* feat: Playback speed controls for music by @pgrit in https://github.com/eddyizm/tempus/pull/376
## New Contributors
* @pgrit made their first contribution in https://github.com/eddyizm/tempus/pull/375
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.6.4...v4.9.1
## What's Changed
## [4.6.4](https://github.com/eddyizm/tempo/releases/tag/v4.6.4) (2026-01-13)
* fix: instant mix random songs and broken continuous play by @eddyizm in https://github.com/eddyizm/tempus/pull/354
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.6.3...v4.6.4
## What's Changed
## [4.6.3](https://github.com/eddyizm/tempo/releases/tag/v4.6.3) (2026-01-10)
* fix: give user feedback when trying to add podcast/radio on unsupport… by @eddyizm in https://github.com/eddyizm/tempus/pull/328
* docs: Clarify Android Auto enablement by @Forage in https://github.com/eddyizm/tempus/pull/336
* fix: instant mix gets a big refactor, with cascading fallbacks to produce a larger queue by @eddyizm in https://github.com/eddyizm/tempus/pull/330
* chore(i18n): add missing keys, update Chinese translation and alphabetize by @hongwei1203 in https://github.com/eddyizm/tempus/pull/332
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/339
* feat: Ability to toggle visibility of artist biography by @kmarius in https://github.com/eddyizm/tempus/pull/338
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.6.0...v4.6.3
## [4.6.0](https://github.com/eddyizm/tempo/releases/tag/v4.6.0) (2025-12-22)
## What's Changed
* chore: Update description_empty_title in English and Polish by @tyren234 in https://github.com/eddyizm/tempus/pull/307
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/310
* fix: checks preference and writes files externally, updates the ui by @eddyizm in https://github.com/eddyizm/tempus/pull/312
* chore: Update description_empty_title in Italian by @pochopsp in https://github.com/eddyizm/tempus/pull/314
* chore: Update description_empty_title in French and Spanish by @pochopsp in https://github.com/eddyizm/tempus/pull/315
* feat: added regular playlist to home view by @eddyizm in https://github.com/eddyizm/tempus/pull/322
## New Contributors
* @tyren234 made their first contribution in https://github.com/eddyizm/tempus/pull/307
* @pochopsp made their first contribution in https://github.com/eddyizm/tempus/pull/314
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.5.0...v4.6.0
## [4.5.0](https://github.com/eddyizm/tempo/releases/tag/v4.5.0) (2025-12-12)
## What's Changed
* fix: updates starred syncing downloads to user defined directory by @eddyizm in https://github.com/eddyizm/tempus/pull/298
* fix: handle empty albums and null mappings by @eddyizm in https://github.com/eddyizm/tempus/pull/301
* feat: integrate sort recent searches chronologically by @J4mm3ris in https://github.com/eddyizm/tempus/pull/300
* feat: add heart to artist/album pages, fixed artist cover art failing by @eddyizm in https://github.com/eddyizm/tempus/pull/303
## New Contributors
* @J4mm3ris made their first contribution in https://github.com/eddyizm/tempus/pull/300
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.4.0...v4.5.0
## [4.4.0](https://github.com/eddyizm/tempo/releases/tag/v4.4.0) (2025-11-29)
## What's Changed
* chore: bringing in media service refactor previously reverted after more testing by @eddyizm in https://github.com/eddyizm/tempus/pull/286
* fix: refactor start queue to put the db writing in the background to address instant mix bug by @eddyizm in https://github.com/eddyizm/tempus/pull/287
* Feat: playerqueue fab allows playqueue actions -> saving to playlist, download all, load queue, shuffle, clean queue by @eddyizm in https://github.com/eddyizm/tempus/pull/288
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/291
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.3.0...v4.4.0
## [4.3.0](https://github.com/eddyizm/tempo/releases/tag/v4.3.0) (2025-11-23) ## [4.3.0](https://github.com/eddyizm/tempo/releases/tag/v4.3.0) (2025-11-23)
## What's Changed ## What's Changed

View file

@ -10,21 +10,15 @@
<div align="center"> <div align="center">
<a href="https://github.com/eddyizm/tempus/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/eddyizm/tempus/total.svg?color=4B95DE&style=flat">
</a>
<!-- Reproducible build --> <!-- Reproducible build -->
<a href="https://shields.rbtlog.dev/com.eddyizm.degoogled.tempus"><img src="https://shields.rbtlog.dev/simple/com.eddyizm.degoogled.tempus" alt="RB Status"></a> [<img src="https://shields.rbtlog.dev/simple/com.eddyizm.degoogled.tempus" alt="RB Status">](https://shields.rbtlog.dev/com.eddyizm.degoogled.tempus)
<a href="https://www.gnu.org/licenses/gpl-3.0">
<img src="https://img.shields.io/badge/license-GPL%20v3-2B6DBE.svg?style=flat">
</a>
</div> </div>
<p align="center"> <p align="center">
<a href="https://github.com/eddyizm/tempus/releases"><img src="https://i.ibb.co/q0mdc4Z/get-it-on-github.png" width="200"></a> <a href="https://github.com/eddyizm/tempus/releases"><img src="https://i.ibb.co/q0mdc4Z/get-it-on-github.png" width="200"></a>
<a href="https://apt.izzysoft.de/fdroid/index/apk/com.eddyizm.degoogled.tempus"><img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" width="200"></a> <a href="https://apt.izzysoft.de/fdroid/index/apk/com.eddyizm.degoogled.tempus"><img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" width="200"></a>
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.eddyizm.tempus%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Feddyizm%2Ftempus%22%2C%22author%22%3A%22eddyizm%22%2C%22name%22%3A%22Tempus%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22tempus%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22includeZips%5C%22%3Afalse%2C%5C%22zippedApkFilterRegEx%5C%22%3A%5C%22%5C%22%7D%22%2C%22overrideSource%22%3A%22GitHub%22%7D"><img width="200" src="https://github.com/user-attachments/assets/119e7ff4-2636-43cb-ab7f-1b6a58ac3570" /></a> <a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.eddyizm.tempus%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Feddyizm%2Ftempus%22%2C%22author%22%3A%22eddyizm%22%2C%22name%22%3A%22Tempus%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22tempus%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22includeZips%5C%22%3Afalse%2C%5C%22zippedApkFilterRegEx%5C%22%3A%5C%22%5C%22%7D%22%2C%22overrideSource%22%3A%22GitHub%22%7D"><img width="200" src="https://github.com/user-attachments/assets/119e7ff4-2636-43cb-ab7f-1b6a58ac3570" /></a>
<a href="https://www.openapk.net/tempus/com.eddyizm.degoogled.tempus/"><img src="https://camo.githubusercontent.com/cd56895b28a73ebd781a65b4f567add5419e45797a5cf1485ce408e851c2318e/68747470733a2f2f7777772e6f70656e61706b2e6e65742f696d616765732f6f70656e61706b2d62616467652e706e67" width="200"></a>
</p> </p>
<!-- <!--
<a href="https://f-droid.org/packages/com.cappielloantonio.notquitemy.tempo"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="200"></a> <a href="https://f-droid.org/packages/com.cappielloantonio.notquitemy.tempo"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="200"></a>
@ -33,14 +27,10 @@
**Tempus** is an open-source and lightweight music client for Subsonic, designed and built natively for Android. It provides a seamless and intuitive music streaming experience, allowing you to access and play your Subsonic music library directly from your Android device. **Tempus** is an open-source and lightweight music client for Subsonic, designed and built natively for Android. It provides a seamless and intuitive music streaming experience, allowing you to access and play your Subsonic music library directly from your Android device.
Tempus does not rely on magic algorithms to decide what you should listen to. Instead, the interface is built around your listening history, randomness, and optionally integrates with services like Listenbrainz.org and Last.fm to personalize your music experience (These must be supported by your backend). Tempus does not rely on magic algorithms to decide what you should listen to. Instead, the interface is built around your listening history, randomness, and optionally integrates with services like Last.fm to personalize your music experience.
The project is a fork of [Tempo](#credits). The project is a fork of [Tempo](#credits).
[Changelog](CHANGELOG.md)
[Wiki](USAGE.md)
[Donate](https://github.com/eddyizm/tempus#donate)
**If you find Tempus useful, please consider starring the project on GitHub. It would mean a lot to me and help promote the app to a wider audience.** **If you find Tempus useful, please consider starring the project on GitHub. It would mean a lot to me and help promote the app to a wider audience.**
**Use the Github version of the app for full Android Auto and Chromecast support.** **Use the Github version of the app for full Android Auto and Chromecast support.**
@ -56,6 +46,11 @@ Please note the two variants in the release assets include release/debug and 32/
`app-degoogled*` <- The izzyOnDroid release that goes without any of the google stuff. It is now available on izzyOnDroid (64bit) I am releasing the both 32/64bit apk's here on github for those who need a 32bit version. `app-degoogled*` <- The izzyOnDroid release that goes without any of the google stuff. It is now available on izzyOnDroid (64bit) I am releasing the both 32/64bit apk's here on github for those who need a 32bit version.
[CHANGELOG.md](CHANGELOG.md)
## Usage
[Documentation](USAGE.md) (work in progress)
## Features ## Features
- **Subsonic Integration**: Tempus seamlessly integrates with your Subsonic server, providing you with easy access to your entire music collection on the go. - **Subsonic Integration**: Tempus seamlessly integrates with your Subsonic server, providing you with easy access to your entire music collection on the go.
@ -64,19 +59,16 @@ Please note the two variants in the release assets include release/debug and 32/
- **Streaming and Offline Mode**: Stream music directly from your Subsonic server. Offline mode is currently under active development and may have limitations when using multiple servers. - **Streaming and Offline Mode**: Stream music directly from your Subsonic server. Offline mode is currently under active development and may have limitations when using multiple servers.
- **Playlist Management**: Create, edit, and manage playlists to curate your perfect music collection. - **Playlist Management**: Create, edit, and manage playlists to curate your perfect music collection.
- **Gapless Playback**: Experience uninterrupted playback with gapless listening mode. - **Gapless Playback**: Experience uninterrupted playback with gapless listening mode.
- **Chromecast Support**: Stream your music to Chromecast devices. The support is currently in a rudimentary state.* - **Chromecast Support**: Stream your music to Chromecast devices. The support is currently in a rudimentary state.
- **Scrobbling Integration**: Optionally integrate Tempus with Last.fm or Listenbrainz.org to scrobble your played tracks, gather music insights, and further personalize your music recommendations, if supported by your Subsonic server. - **Scrobbling Integration**: Optionally integrate Tempus with Last.fm or Listenbrainz.org to scrobble your played tracks, gather music insights, and further personalize your music recommendations, if supported by your Subsonic server.
- **Podcasts and Radio**: If your Subsonic server supports it, listen to podcasts and radio shows directly within Tempus, expanding your audio entertainment options. - **Podcasts and Radio**: If your Subsonic server supports it, listen to podcasts and radio shows directly within Tempus, expanding your audio entertainment options.
- **Instant Mix**: Full refactor of instant mix function which leverages subsonics similarSongs2 by artist/album and similarSongs endpoints to server a larger play queue more reliably.
- **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. - **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.* - **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**: Tempus handles multi-library setups gracefully. They are displayed as Library folders. - **Multiple Libraries**: Tempus handles multi-library setups gracefully. They are displayed as Library folders.
- **Equalizer**: Option to use in app equalizer. - **Equalizer**: Option to use in app equalizer.
- **Widget**: New widget to keeping the basic controls on your screen at all times. - **Widget**: New widget to keeping the basic controls on your screen at all times.
- **Available in 11 languages**: Currently in Chinese, French, German, Italian, Korean, Polish, Portuguese, Russion, Spanish and Turkish - **Available in 11 languages**: Currently in Chinese, French, German, Italian, Korean, Polish, Portuguese, Russion, Spanish and Turkish
**Github version only*
## Screenshot ## Screenshot
<p align="center"> <p align="center">
@ -120,10 +112,7 @@ 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. Not a hard requirement but any new feature/change should ideally include an update to the nacent documention.
*Special Thanks* ## Support
All the amazing [contributors](https://github.com/eddyizm/tempus/graphs/contributors)❤️
## Donate
[**Buy me a coffee**](https://ko-fi.com/eddyizm) [**Buy me a coffee**](https://ko-fi.com/eddyizm)
bitcoin: `3QVHSSCJvn6yXEcJ3A3cxYLMmbvFsrnUs5` bitcoin: `3QVHSSCJvn6yXEcJ3A3cxYLMmbvFsrnUs5`
@ -132,7 +121,6 @@ bitcoin: `3QVHSSCJvn6yXEcJ3A3cxYLMmbvFsrnUs5`
Tempus is released under the [GNU General Public License v3.0](LICENSE). Feel free to modify, distribute, and use the app in accordance with the terms of the license. Contributions to the project are also welcome. Tempus is released under the [GNU General Public License v3.0](LICENSE). Feel free to modify, distribute, and use the app in accordance with the terms of the license. Contributions to the project are also welcome.
## Credits ## Credits
Thanks to the original repo/creator [CappielloAntonio](https://github.com/CappielloAntonio) (forked from v3.9.0) Thanks to the original repo/creator [CappielloAntonio](https://github.com/CappielloAntonio) (forked from v3.9.0)

View file

@ -6,13 +6,14 @@
- [Getting Started](#getting-started) - [Getting Started](#getting-started)
- [Server Configuration](#server-configuration) - [Server Configuration](#server-configuration)
- [Main Features](#main-features) - [Main Features](#main-features)
- [Navigation](#navigation) - [Navigation](#navigation)
- [Playback Controls](#playback-controls) - [Playback Controls](#playback-controls)
- [Favorites](#favorites) - [Favorites](#favorites)
- [Playlist Management](#playlist-management) - [Playlist Management](#playlist-management)
- [Android Auto](#android-auto) - [Android Auto](#android-auto)
- [Settings](#settings) - [Settings](#settings)
- [Known Issues](#known-issues) - [Troubleshooting](#troubleshooting)
## Prerequisites ## Prerequisites
@ -29,7 +30,7 @@ This app works with any service that implements the Subsonic API, including:
- [Gonic](https://github.com/sentriz/gonic) - [Gonic](https://github.com/sentriz/gonic)
- [Ampache](https://github.com/ampache/ampache) - [Ampache](https://github.com/ampache/ampache)
- [NextCloud Music](https://apps.nextcloud.com/apps/music) - [NextCloud Music](https://apps.nextcloud.com/apps/music)
- [Airsonic Advanced](https://github.com/kagemomiji/airsonic-advanced)
@ -159,23 +160,7 @@ If your server supports it - add a internet radio station feed
## Android Auto ## Android Auto
### Enabling on your head unit ### Enabling on your head unit
To allow the Tempus app on your car's head unit, "Unknown sources" needs to be enabled in the Android Auto "Developer settings". This is because Tempus isn't installed through Play Store. Note that the Android Auto developer settings are different from the global Android "Developer options". - You have to enable Android Auto developer options, which are different from actual Android dev options. Then you have to enable "Unknown sources" in Android Auto, otherwise the app won't appear as it isn't downloaded from Play Store. (screenshots needed)
1. Switch to developer mode in the Android Auto settings by tapping ten times on the "Version" item at the bottom, followed by giving your permission.
<p align="left">
<img width="270" height="600" alt="1a" src="https://github.com/user-attachments/assets/f09f6999-9761-4b05-8ec7-bf221a15dda3" />
<img width="270" height="600" alt="1b" src="https://github.com/user-attachments/assets/0795e508-ba01-41c5-96a7-7c03b0156591" />
<img width="270" height="600" alt="1c" src="https://github.com/user-attachments/assets/51c15f67-fddb-452e-b5d3-5092edeab390" />
</p>
2. Go to the "Developer settings" by the menu at the top right.
<p align="left">
<img width="270" height="600" alt="2" src="https://github.com/user-attachments/assets/1ecd1f3e-026d-4d25-87f2-be7f12efbac6" />
</p>
3. Scroll down to the bottom and check "Unknown sources".
<p align="left">
<img width="270" height="600" alt="3" src="https://github.com/user-attachments/assets/37db88e9-1b76-417f-9c47-da9f3a750fff" />
</p>
### Server Settings ### Server Settings
@ -192,12 +177,15 @@ To allow the Tempus app on your car's head unit, "Unknown sources" needs to be e
### Appearance ### Appearance
**TODO** **TODO**
## Known Issues ## Troubleshooting
### Airsonic Distorted Playback ### Connection Issues
First reported in issue [#226](https://github.com/eddyizm/tempus/issues/226) **TODO**
The work around is to disable the cache in the settings, (set to 0), and if needed, cleaning the (Android) cache fixes the problem.
### Common Issues
**TODO**
### Support ### Support
For additional help: For additional help:

View file

@ -10,8 +10,8 @@ android {
minSdkVersion 24 minSdkVersion 24
targetSdk 35 targetSdk 35
versionCode 22 versionCode 8
versionName '4.12.4' versionName '4.3.0'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
javaCompileOptions { javaCompileOptions {
@ -101,7 +101,6 @@ dependencies {
implementation 'androidx.room:room-runtime:2.6.1' implementation 'androidx.room:room-runtime:2.6.1'
implementation 'androidx.core:core-splashscreen:1.0.1' implementation 'androidx.core:core-splashscreen:1.0.1'
implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.appcompat:appcompat:1.7.0'
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0"
// Android Material // Android Material
implementation 'com.google.android.material:material:1.10.0' implementation 'com.google.android.material:material:1.10.0'

View file

@ -1,6 +1,579 @@
package com.cappielloantonio.tempo.service package com.cappielloantonio.tempo.service
import android.annotation.SuppressLint
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.TaskStackBuilder
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.os.Binder
import android.os.Bundle
import android.os.IBinder
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.media3.common.*
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
import androidx.media3.session.*
import androidx.media3.session.MediaSession.ControllerInfo
import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.repository.QueueRepository
import com.cappielloantonio.tempo.ui.activity.MainActivity
import com.cappielloantonio.tempo.util.AssetLinkUtil
import com.cappielloantonio.tempo.util.Constants
import com.cappielloantonio.tempo.util.DownloadUtil
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
import com.cappielloantonio.tempo.util.MappingUtil
import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil
import com.cappielloantonio.tempo.widget.WidgetUpdateManager
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
@UnstableApi @UnstableApi
class MediaService : BaseMediaService() class MediaService : MediaLibraryService() {
private val TAG = "MediaService"
private val librarySessionCallback = CustomMediaLibrarySessionCallback()
private lateinit var player: ExoPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var shuffleCommands: List<CommandButton>
private lateinit var repeatCommands: List<CommandButton>
private lateinit var networkCallback: CustomNetworkCallback
lateinit var equalizerManager: EqualizerManager
private var customLayout = ImmutableList.of<CommandButton>()
private val widgetUpdateHandler = Handler(Looper.getMainLooper())
private var widgetUpdateScheduled = false
private val widgetUpdateRunnable = object : Runnable {
override fun run() {
if (!player.isPlaying) {
widgetUpdateScheduled = false
return
}
updateWidget()
widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
}
}
inner class LocalBinder : Binder() {
fun getEqualizerManager(): EqualizerManager {
return this@MediaService.equalizerManager
}
}
private val binder = LocalBinder()
companion object {
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
"android.media3.session.demo.SHUFFLE_ON"
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF =
"android.media3.session.demo.SHUFFLE_OFF"
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF =
"android.media3.session.demo.REPEAT_OFF"
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE =
"android.media3.session.demo.REPEAT_ONE"
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL =
"android.media3.session.demo.REPEAT_ALL"
const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER"
const val ACTION_EQUALIZER_UPDATED = "com.cappielloantonio.tempo.service.EQUALIZER_UPDATED"
}
fun updateMediaItems() {
Log.d(TAG, "update items");
val n = player.mediaItemCount
val k = player.currentMediaItemIndex
val current = player.currentPosition
val items = (0 .. n-1).map{i -> MappingUtil.mapMediaItem(player.getMediaItemAt(i))}
player.clearMediaItems()
player.setMediaItems(items, k, current)
}
inner class CustomNetworkCallback : ConnectivityManager.NetworkCallback() {
var wasWifi = false
init {
val manager = getSystemService(ConnectivityManager::class.java)
val network = manager.activeNetwork
val capabilities = manager.getNetworkCapabilities(network)
if (capabilities != null)
wasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
}
override fun onCapabilitiesChanged(network : Network, networkCapabilities : NetworkCapabilities) {
val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
if (isWifi != wasWifi) {
wasWifi = isWifi
widgetUpdateHandler.post(Runnable {
updateMediaItems()
})
}
}
}
override fun onCreate() {
super.onCreate()
initializeCustomCommands()
initializePlayer()
initializeMediaLibrarySession()
restorePlayerFromQueue()
initializePlayerListener()
initializeEqualizerManager()
initializeNetworkListener()
setPlayer(player)
}
override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
return mediaLibrarySession
}
override fun onDestroy() {
releaseNetworkCallback()
equalizerManager.release()
stopWidgetUpdates()
releasePlayer()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? {
// Check if the intent is for our custom equalizer binder
if (intent?.action == ACTION_BIND_EQUALIZER) {
return binder
}
// Otherwise, handle it as a normal MediaLibraryService connection
return super.onBind(intent)
}
private inner class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback {
override fun onConnect(
session: MediaSession,
controller: ControllerInfo
): MediaSession.ConnectionResult {
val connectionResult = super.onConnect(session, controller)
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
(shuffleCommands + repeatCommands).forEach { commandButton ->
commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
}
customLayout = buildCustomLayout(session.player)
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(availableSessionCommands.build())
.setAvailablePlayerCommands(connectionResult.availablePlayerCommands)
.setCustomLayout(customLayout)
.build()
}
override fun onPostConnect(session: MediaSession, controller: ControllerInfo) {
if (!customLayout.isEmpty() && controller.controllerVersion != 0) {
ignoreFuture(mediaLibrarySession.setCustomLayout(controller, customLayout))
}
}
fun buildCustomLayout(player: Player): ImmutableList<CommandButton> {
val shuffle = shuffleCommands[if (player.shuffleModeEnabled) 1 else 0]
val repeat = when (player.repeatMode) {
Player.REPEAT_MODE_ONE -> repeatCommands[1]
Player.REPEAT_MODE_ALL -> repeatCommands[2]
else -> repeatCommands[0]
}
return ImmutableList.of(shuffle, repeat)
}
override fun onCustomCommand(
session: MediaSession,
controller: ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
when (customCommand.customAction) {
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> player.shuffleModeEnabled = true
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> player.shuffleModeEnabled = false
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> {
val nextMode = when (player.repeatMode) {
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE
else -> Player.REPEAT_MODE_OFF
}
player.repeatMode = nextMode
}
}
customLayout = librarySessionCallback.buildCustomLayout(player)
session.setCustomLayout(customLayout)
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: ControllerInfo,
mediaItems: List<MediaItem>
): ListenableFuture<List<MediaItem>> {
val updatedMediaItems = mediaItems.map { mediaItem ->
val mediaMetadata = mediaItem.mediaMetadata
val newMetadata = mediaMetadata.buildUpon()
.setArtist(
if (mediaMetadata.artist != null) mediaMetadata.artist
else mediaMetadata.extras?.getString("uri") ?: ""
)
.build()
mediaItem.buildUpon()
.setUri(mediaItem.requestMetadata.mediaUri)
.setMediaMetadata(newMetadata)
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)
.build()
}
return Futures.immediateFuture(updatedMediaItems)
}
}
private fun initializeCustomCommands() {
shuffleCommands = listOf(
getShuffleCommandButton(
SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY)
),
getShuffleCommandButton(
SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY)
)
)
repeatCommands = listOf(
getRepeatCommandButton(
SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, Bundle.EMPTY)
),
getRepeatCommandButton(
SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, Bundle.EMPTY)
),
getRepeatCommandButton(
SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, Bundle.EMPTY)
)
)
customLayout = ImmutableList.of(shuffleCommands[0], repeatCommands[0])
}
private fun initializePlayer() {
player = ExoPlayer.Builder(this)
.setRenderersFactory(getRenderersFactory())
.setMediaSourceFactory(getMediaSourceFactory())
.setAudioAttributes(AudioAttributes.DEFAULT, true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
.setLoadControl(initializeLoadControl())
.build()
player.shuffleModeEnabled = Preferences.isShuffleModeEnabled()
player.repeatMode = Preferences.getRepeatMode()
}
private fun initializeEqualizerManager() {
equalizerManager = EqualizerManager()
val audioSessionId = player.audioSessionId
attachEqualizerIfPossible(audioSessionId)
}
private fun initializeMediaLibrarySession() {
val sessionActivityPendingIntent =
TaskStackBuilder.create(this).run {
addNextIntent(Intent(this@MediaService, MainActivity::class.java))
getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
}
mediaLibrarySession =
MediaLibrarySession.Builder(this, player, librarySessionCallback)
.setSessionActivity(sessionActivityPendingIntent)
.build()
if (!customLayout.isEmpty()) {
mediaLibrarySession.setCustomLayout(customLayout)
}
}
private fun initializeNetworkListener() {
networkCallback = CustomNetworkCallback()
getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(networkCallback)
updateMediaItems()
}
private fun restorePlayerFromQueue() {
if (player.mediaItemCount > 0) return
val queueRepository = QueueRepository()
val storedQueue = queueRepository.media
if (storedQueue.isNullOrEmpty()) return
val mediaItems = MappingUtil.mapMediaItems(storedQueue)
if (mediaItems.isEmpty()) return
val lastIndex = try {
queueRepository.lastPlayedMediaIndex
} catch (_: Exception) {
0
}.coerceIn(0, mediaItems.size - 1)
val lastPosition = try {
queueRepository.lastPlayedMediaTimestamp
} catch (_: Exception) {
0L
}.let { if (it < 0L) 0L else it }
player.setMediaItems(mediaItems, lastIndex, lastPosition)
player.prepare()
updateWidget()
}
private fun initializePlayerListener() {
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (mediaItem == null) return
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem)
}
updateWidget()
}
override fun onTracksChanged(tracks: Tracks) {
Log.d(TAG, "onTracksChanged " + player.currentMediaItemIndex);
ReplayGainUtil.setReplayGain(player, tracks)
val currentMediaItem = player.currentMediaItem
if (currentMediaItem != null) {
val item = MappingUtil.mapMediaItem(currentMediaItem)
if (item.mediaMetadata.extras != null)
MediaManager.scrobble(item, false)
if (player.nextMediaItemIndex == C.INDEX_UNSET)
MediaManager.continuousPlay(player.currentMediaItem)
}
// https://stackoverflow.com/questions/56937283/exoplayer-shuffle-doesnt-reproduce-all-the-songs
if (MediaManager.justStarted.get()) {
Log.d(TAG, "update shuffle order")
MediaManager.justStarted.set(false)
val shuffledList = IntArray(player.mediaItemCount) { i -> i }
shuffledList.shuffle()
val index = shuffledList.indexOf(player.currentMediaItemIndex)
// swap current media index to the first index
if (index > -1 && shuffledList.isNotEmpty())
run { val tmp = shuffledList[0]; shuffledList[0] = shuffledList[index]; shuffledList[index] = tmp}
player.shuffleOrder = DefaultShuffleOrder(shuffledList, kotlin.random.Random.nextLong())
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (!isPlaying) {
MediaManager.setPlayingPausedTimestamp(
player.currentMediaItem,
player.currentPosition
)
} else {
MediaManager.scrobble(player.currentMediaItem, false)
}
if (isPlaying) {
scheduleWidgetUpdates()
} else {
stopWidgetUpdates()
}
updateWidget()
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
if (!player.hasNextMediaItem() &&
playbackState == Player.STATE_ENDED &&
player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
) {
MediaManager.scrobble(player.currentMediaItem, true)
MediaManager.saveChronology(player.currentMediaItem)
}
updateWidget()
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) {
if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
MediaManager.scrobble(oldPosition.mediaItem, true)
MediaManager.saveChronology(oldPosition.mediaItem)
}
if (newPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
MediaManager.setLastPlayedTimestamp(newPosition.mediaItem)
}
}
}
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
customLayout = librarySessionCallback.buildCustomLayout(player)
mediaLibrarySession.setCustomLayout(customLayout)
}
override fun onRepeatModeChanged(repeatMode: Int) {
Preferences.setRepeatMode(repeatMode)
customLayout = librarySessionCallback.buildCustomLayout(player)
mediaLibrarySession.setCustomLayout(customLayout)
}
override fun onAudioSessionIdChanged(audioSessionId: Int) {
attachEqualizerIfPossible(audioSessionId)
}
})
if (player.isPlaying) {
scheduleWidgetUpdates()
}
}
private fun setPlayer(player: Player) {
mediaLibrarySession.player = player
}
private fun releasePlayer() {
player.release()
mediaLibrarySession.release()
}
private fun releaseNetworkCallback() {
getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback)
}
@SuppressLint("PrivateResource")
private fun getShuffleCommandButton(sessionCommand: SessionCommand): CommandButton {
val isOn = sessionCommand.customAction == CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON
return CommandButton.Builder()
.setDisplayName(
getString(
if (isOn) R.string.exo_controls_shuffle_on_description
else R.string.exo_controls_shuffle_off_description
)
)
.setSessionCommand(sessionCommand)
.setIconResId(if (isOn) R.drawable.exo_icon_shuffle_off else R.drawable.exo_icon_shuffle_on)
.build()
}
@SuppressLint("PrivateResource")
private fun getRepeatCommandButton(sessionCommand: SessionCommand): CommandButton {
val icon = when (sessionCommand.customAction) {
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> R.drawable.exo_icon_repeat_one
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> R.drawable.exo_icon_repeat_all
else -> R.drawable.exo_icon_repeat_off
}
val description = when (sessionCommand.customAction) {
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> R.string.exo_controls_repeat_one_description
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> R.string.exo_controls_repeat_all_description
else -> R.string.exo_controls_repeat_off_description
}
return CommandButton.Builder()
.setDisplayName(getString(description))
.setSessionCommand(sessionCommand)
.setIconResId(icon)
.build()
}
private fun ignoreFuture(@Suppress("UNUSED_PARAMETER") customLayout: ListenableFuture<SessionResult>) {
/* Do nothing. */
}
private fun initializeLoadControl(): DefaultLoadControl {
return DefaultLoadControl.Builder()
.setBufferDurationsMs(
(DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
(DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
.build()
}
private fun updateWidget() {
val mi = player.currentMediaItem
val title = mi?.mediaMetadata?.title?.toString()
?: mi?.mediaMetadata?.extras?.getString("title")
val artist = mi?.mediaMetadata?.artist?.toString()
?: mi?.mediaMetadata?.extras?.getString("artist")
val album = mi?.mediaMetadata?.albumTitle?.toString()
?: mi?.mediaMetadata?.extras?.getString("album")
val extras = mi?.mediaMetadata?.extras
val coverId = extras?.getString("coverArtId")
val songLink = extras?.getString("assetLinkSong")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id"))
val albumLink = extras?.getString("assetLinkAlbum")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId"))
val artistLink = extras?.getString("assetLinkArtist")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId"))
val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
WidgetUpdateManager.updateFromState(
this,
title ?: "",
artist ?: "",
album ?: "",
coverId,
player.isPlaying,
player.shuffleModeEnabled,
player.repeatMode,
position,
duration,
songLink,
albumLink,
artistLink
)
}
private fun scheduleWidgetUpdates() {
if (widgetUpdateScheduled) return
widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS)
widgetUpdateScheduled = true
}
private fun stopWidgetUpdates() {
if (!widgetUpdateScheduled) return
widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable)
widgetUpdateScheduled = false
}
private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean {
if (audioSessionId == 0 || audioSessionId == -1) return false
val attached = equalizerManager.attachToSession(audioSessionId)
if (attached) {
val enabled = Preferences.isEqualizerEnabled()
equalizerManager.setEnabled(enabled)
val bands = equalizerManager.getNumberOfBands()
val savedLevels = Preferences.getEqualizerBandLevels(bands)
for (i in 0 until bands) {
equalizerManager.setBandLevel(i.toShort(), savedLevels[i])
}
sendBroadcast(Intent(ACTION_EQUALIZER_UPDATED))
}
return attached
}
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
private fun getMediaSourceFactory(): MediaSource.Factory = DynamicMediaSourceFactory(this)
}
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L

View file

@ -96,12 +96,7 @@
android:resource="@xml/widget_info"/> android:resource="@xml/widget_info"/>
</receiver> </receiver>
<provider
android:name=".provider.AlbumArtContentProvider"
android:authorities="${applicationId}.albumart.provider"
android:enabled="true"
android:exported="true"
/>
</application> </application>
</manifest> </manifest>

View file

@ -11,7 +11,6 @@ import com.cappielloantonio.tempo.github.Github;
import com.cappielloantonio.tempo.helper.ThemeHelper; import com.cappielloantonio.tempo.helper.ThemeHelper;
import com.cappielloantonio.tempo.subsonic.Subsonic; import com.cappielloantonio.tempo.subsonic.Subsonic;
import com.cappielloantonio.tempo.subsonic.SubsonicPreferences; import com.cappielloantonio.tempo.subsonic.SubsonicPreferences;
import com.cappielloantonio.tempo.util.ClientCertManager;
import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.Preferences;
public class App extends Application { public class App extends Application {
@ -32,8 +31,6 @@ public class App extends Application {
instance = new App(); instance = new App();
context = getApplicationContext(); context = getApplicationContext();
preferences = PreferenceManager.getDefaultSharedPreferences(context); preferences = PreferenceManager.getDefaultSharedPreferences(context);
ClientCertManager.setupSslSocketFactory(context);
} }
public static App getInstance() { public static App getInstance() {
@ -59,48 +56,6 @@ public class App extends Application {
return subsonic; return subsonic;
} }
public static Subsonic getSubsonicPublicClientInstance(boolean override) {
/*
If I do the shortcut that the IDE suggests:
SubsonicPreferences preferences = getSubsonicPreferences1();
During the chain of calls it will run the following:
String server = Preferences.getInUseServerAddress();
Which could return Local URL, causing issues like generating public shares with Local URL
To prevent this I just replicated the entire chain of functions here,
if you need a call to Subsonic using the Server (Public) URL use this function.
*/
String server = Preferences.getServer();
String username = Preferences.getUser();
String password = Preferences.getPassword();
String token = Preferences.getToken();
String salt = Preferences.getSalt();
boolean isLowSecurity = Preferences.isLowScurity();
SubsonicPreferences preferences = new SubsonicPreferences();
preferences.setServerUrl(server);
preferences.setUsername(username);
preferences.setAuthentication(password, token, salt, isLowSecurity);
if (subsonic == null || override) {
if (preferences.getAuthentication() != null) {
if (preferences.getAuthentication().getPassword() != null)
Preferences.setPassword(preferences.getAuthentication().getPassword());
if (preferences.getAuthentication().getToken() != null)
Preferences.setToken(preferences.getAuthentication().getToken());
if (preferences.getAuthentication().getSalt() != null)
Preferences.setSalt(preferences.getAuthentication().getSalt());
}
}
return new Subsonic(preferences);
}
public static Github getGithubClientInstance() { public static Github getGithubClientInstance() {
if (github == null) { if (github == null) {
github = new Github(); github = new Github();

View file

@ -30,13 +30,9 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
@UnstableApi @UnstableApi
@Database( @Database(
version = 14, version = 12,
entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class, LyricsCache.class}, entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class, LyricsCache.class},
autoMigrations = { autoMigrations = {@AutoMigration(from = 10, to = 11), @AutoMigration(from = 11, to = 12)}
@AutoMigration(from = 10, to = 11),
@AutoMigration(from = 11, to = 12),
@AutoMigration(from = 13, to = 14),
}
) )
@TypeConverters({DateConverters.class}) @TypeConverters({DateConverters.class})
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {

View file

@ -19,9 +19,6 @@ public interface PlaylistDao {
@Query("SELECT * FROM playlist") @Query("SELECT * FROM playlist")
LiveData<List<Playlist>> getAll(); LiveData<List<Playlist>> getAll();
@Query("SELECT * FROM playlist")
List<Playlist> getAllSync();
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
void insert(Playlist playlist); void insert(Playlist playlist);

View file

@ -12,12 +12,9 @@ import java.util.List;
@Dao @Dao
public interface RecentSearchDao { public interface RecentSearchDao {
@Query("SELECT search FROM recent_search ORDER BY timestamp DESC") @Query("SELECT * FROM recent_search ORDER BY search DESC")
List<String> getRecent(); List<String> getRecent();
@Query("SELECT search FROM recent_search ORDER BY search DESC")
List<String> getAlpha();
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
void insert(RecentSearch search); void insert(RecentSearch search);

View file

@ -13,8 +13,5 @@ import kotlinx.parcelize.Parcelize
data class RecentSearch( data class RecentSearch(
@PrimaryKey @PrimaryKey
@ColumnInfo(name = "search") @ColumnInfo(name = "search")
var search: String, var search: String
@ColumnInfo(name = "timestamp", defaultValue = "0")
var timestamp: Long
) : Parcelable ) : Parcelable

View file

@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.model
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.Keep import androidx.annotation.Keep
import androidx.annotation.Nullable
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@ -34,8 +35,5 @@ data class Server(
val timestamp: Long, val timestamp: Long,
@ColumnInfo(name = "low_security", defaultValue = "false") @ColumnInfo(name = "low_security", defaultValue = "false")
val isLowSecurity: Boolean, val isLowSecurity: Boolean
@ColumnInfo(name = "client_cert")
val clientCert: String?,
) : Parcelable ) : Parcelable

View file

@ -1,6 +1,5 @@
package com.cappielloantonio.tempo.model package com.cappielloantonio.tempo.model
import android.content.ContentResolver
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.annotation.Keep import androidx.annotation.Keep
@ -14,7 +13,6 @@ import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.cappielloantonio.tempo.glide.CustomGlideRequest import com.cappielloantonio.tempo.glide.CustomGlideRequest
import com.cappielloantonio.tempo.provider.AlbumArtContentProvider
import com.cappielloantonio.tempo.subsonic.models.Child import com.cappielloantonio.tempo.subsonic.models.Child
import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode
@ -195,20 +193,11 @@ class SessionMediaItem() {
title = internetRadioStation.name title = internetRadioStation.name
streamUrl = internetRadioStation.streamUrl streamUrl = internetRadioStation.streamUrl
type = Constants.MEDIA_TYPE_RADIO type = Constants.MEDIA_TYPE_RADIO
val homePageUrl = internetRadioStation.homePageUrl
if (homePageUrl != null && homePageUrl.isNotEmpty() && MusicUtil.isImageUrl(homePageUrl)) {
val encodedUrl = android.util.Base64.encodeToString(
homePageUrl.toByteArray(java.nio.charset.StandardCharsets.UTF_8),
android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP
)
coverArtId = "ir_$encodedUrl"
}
} }
fun getMediaItem(): MediaItem { fun getMediaItem(): MediaItem {
val uri: Uri = getStreamUri() val uri: Uri = getStreamUri()
val artworkUri = if (coverArtId != null) AlbumArtContentProvider.contentUri(coverArtId!!) else null val artworkUri = Uri.parse(CustomGlideRequest.createUrl(coverArtId, getImageSize()))
val bundle = Bundle() val bundle = Bundle()
bundle.putString("id", id) bundle.putString("id", id)
@ -238,7 +227,7 @@ class SessionMediaItem() {
bundle.putLong("starred", starred?.time ?: 0) bundle.putLong("starred", starred?.time ?: 0)
bundle.putString("albumId", albumId) bundle.putString("albumId", albumId)
bundle.putString("artistId", artistId) bundle.putString("artistId", artistId)
bundle.putString("type", type) bundle.putString("type", Constants.MEDIA_TYPE_MUSIC)
bundle.putLong("bookmarkPosition", bookmarkPosition ?: 0) bundle.putLong("bookmarkPosition", bookmarkPosition ?: 0)
bundle.putInt("originalWidth", originalWidth ?: 0) bundle.putInt("originalWidth", originalWidth ?: 0)
bundle.putInt("originalHeight", originalHeight ?: 0) bundle.putInt("originalHeight", originalHeight ?: 0)

View file

@ -1,159 +0,0 @@
package com.cappielloantonio.tempo.provider;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.util.Base64;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.cappielloantonio.tempo.BuildConfig;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.util.Preferences;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class AlbumArtContentProvider extends ContentProvider {
public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".albumart.provider";
public static final String ALBUM_ART = "albumArt";
private ExecutorService executor;
private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
static {
uriMatcher.addURI(AUTHORITY, "albumArt/*", 1);
}
public static Uri contentUri(String artworkId) {
return new Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority(AUTHORITY)
.appendPath(ALBUM_ART)
.appendPath(artworkId)
.build();
}
@Nullable
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
Context context = getContext();
String albumId = uri.getLastPathSegment();
Uri artworkUri;
if (albumId != null && albumId.startsWith("ir_")) {
String encodedUrl = albumId.substring("ir_".length());
String decodedUrl = new String(Base64.decode(encodedUrl, Base64.URL_SAFE | Base64.NO_WRAP));
artworkUri = Uri.parse(decodedUrl);
} else {
artworkUri = Uri.parse(CustomGlideRequest.createUrl(albumId, Preferences.getImageSize()));
}
try {
// use pipe to communicate between background thread and caller of openFile()
ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe();
ParcelFileDescriptor readSide = pipe[0];
ParcelFileDescriptor writeSide = pipe[1];
// perform loading in background thread to avoid blocking UI
executor.execute(() -> {
try (OutputStream out = new ParcelFileDescriptor.AutoCloseOutputStream(writeSide)) {
// request artwork from API using Glide
File file = Glide.with(context)
.asFile()
.load(artworkUri)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.submit()
.get();
// copy artwork down pipe returned by ContentProvider
try (InputStream in = new FileInputStream(file)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
} catch (Exception e) {
writeSide.closeWithError("Failed to load image: " + e.getMessage());
}
} catch (Exception e) {
try {
writeSide.closeWithError("Failed to load image: " + e.getMessage());
} catch (IOException ignored) {}
}
});
return readSide;
} catch (IOException e) {
throw new FileNotFoundException("Could not create pipe: " + e.getMessage());
}
}
@Override
public boolean onCreate() {
executor = Executors.newFixedThreadPool(
Math.max(2, Runtime.getRuntime().availableProcessors() / 2)
);
return true;
}
@Override
public void shutdown() {
if (executor != null) {
executor.shutdown();
try {
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
}
}
}
@Nullable
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] strings, @Nullable String s, @Nullable String[] strings1, @Nullable String s1) {
return null;
}
@Nullable
@Override
public String getType(@NonNull Uri uri) {
return "";
}
@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues contentValues) {
return null;
}
@Override
public int delete(@NonNull Uri uri, @Nullable String s, @Nullable String[] strings) {
return 0;
}
@Override
public int update(@NonNull Uri uri, @Nullable ContentValues contentValues, @Nullable String s, @Nullable String[] strings) {
return 0;
}
}

View file

@ -2,16 +2,15 @@ package com.cappielloantonio.tempo.repository;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.MutableLiveData;
import android.util.Log; import android.util.Log;
import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.interfaces.DecadesCallback; import com.cappielloantonio.tempo.interfaces.DecadesCallback;
import com.cappielloantonio.tempo.interfaces.MediaCallback;
import com.cappielloantonio.tempo.subsonic.base.ApiResponse; import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3; import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.AlbumInfo; import com.cappielloantonio.tempo.subsonic.models.AlbumInfo;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.util.Constants.SeedType;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Calendar; import java.util.Calendar;
@ -205,11 +204,28 @@ public class AlbumRepository {
return albumInfo; return albumInfo;
} }
public MutableLiveData<List<Child>> getInstantMix(AlbumID3 album, int count) { public void getInstantMix(AlbumID3 album, int count, MediaCallback callback) {
// Delegate to the centralized SongRepository App.getSubsonicClientInstance(false)
return new SongRepository().getInstantMix(album.getId(), SeedType.ALBUM, count); .getBrowsingClient()
.getSimilarSongs2(album.getId(), count)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> songs = new ArrayList<>();
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSimilarSongs2() != null) {
songs.addAll(response.body().getSubsonicResponse().getSimilarSongs2().getSongs());
} }
callback.onLoadMedia(songs);
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
callback.onLoadMedia(new ArrayList<>());
}
});
}
public MutableLiveData<List<Integer>> getDecades() { public MutableLiveData<List<Integer>> getDecades() {
MutableLiveData<List<Integer>> decades = new MutableLiveData<>(); MutableLiveData<List<Integer>> decades = new MutableLiveData<>();
@ -221,7 +237,7 @@ public class AlbumRepository {
@Override @Override
public void onLoadYear(int last) { public void onLoadYear(int last) {
if (first != -1 && last != -1) { if (first != -1 && last != -1) {
List<Integer> decadeList = new ArrayList<>(); List<Integer> decadeList = new ArrayList();
int startDecade = first - (first % 10); int startDecade = first - (first % 10);
int lastDecade = last - (last % 10); int lastDecade = last - (last % 10);

View file

@ -5,14 +5,12 @@ import androidx.lifecycle.MutableLiveData;
import android.util.Log; import android.util.Log;
import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.interfaces.MediaCallback;
import com.cappielloantonio.tempo.subsonic.base.ApiResponse; import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3; import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3; import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistInfo2; import com.cappielloantonio.tempo.subsonic.models.ArtistInfo2;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.IndexID3; import com.cappielloantonio.tempo.subsonic.models.IndexID3;
import com.cappielloantonio.tempo.util.Constants.SeedType;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
@ -151,7 +149,7 @@ public class ArtistRepository {
if(response.body().getSubsonicResponse().getArtists() != null && response.body().getSubsonicResponse().getArtists().getIndices() != null) { if(response.body().getSubsonicResponse().getArtists() != null && response.body().getSubsonicResponse().getArtists().getIndices() != null) {
for (IndexID3 index : response.body().getSubsonicResponse().getArtists().getIndices()) { for (IndexID3 index : response.body().getSubsonicResponse().getArtists().getIndices()) {
if(index.getArtists() != null) { if(index != null && index.getArtists() != null) {
artists.addAll(index.getArtists()); artists.addAll(index.getArtists());
} }
} }
@ -289,8 +287,26 @@ public class ArtistRepository {
} }
public MutableLiveData<List<Child>> getInstantMix(ArtistID3 artist, int count) { public MutableLiveData<List<Child>> getInstantMix(ArtistID3 artist, int count) {
// Delegate to the centralized SongRepository MutableLiveData<List<Child>> instantMix = new MutableLiveData<>();
return new SongRepository().getInstantMix(artist.getId(), SeedType.ARTIST, count);
App.getSubsonicClientInstance(false)
.getBrowsingClient()
.getSimilarSongs2(artist.getId(), count)
.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().getSimilarSongs2() != null) {
instantMix.setValue(response.body().getSubsonicResponse().getSimilarSongs2().getSongs());
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
return instantMix;
} }
public MutableLiveData<List<Child>> getRandomSong(ArtistID3 artist, int count) { public MutableLiveData<List<Child>> getRandomSong(ArtistID3 artist, int count) {

View file

@ -1,6 +1,6 @@
package com.cappielloantonio.tempo.repository; package com.cappielloantonio.tempo.repository;
import android.content.ContentResolver;
import android.net.Uri; import android.net.Uri;
import android.view.View; import android.view.View;
@ -22,7 +22,6 @@ import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.model.Chronology; import com.cappielloantonio.tempo.model.Chronology;
import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.model.SessionMediaItem; import com.cappielloantonio.tempo.model.SessionMediaItem;
import com.cappielloantonio.tempo.provider.AlbumArtContentProvider;
import com.cappielloantonio.tempo.service.DownloaderManager; import com.cappielloantonio.tempo.service.DownloaderManager;
import com.cappielloantonio.tempo.subsonic.base.ApiResponse; import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3; import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
@ -68,20 +67,10 @@ public class AutomotiveRepository {
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) {
List<AlbumID3> albums = response.body().getSubsonicResponse().getAlbumList2().getAlbums(); List<AlbumID3> albums = response.body().getSubsonicResponse().getAlbumList2().getAlbums();
// add by MFO
// Hack for artist view
if("alphabeticalByArtist".equals(type))for(AlbumID3 album : albums){
String artistName = album.getArtist();
String albumName = album.getName();
album.setName(artistName);
album.setArtist(albumName);
}
// end add by MFO
List<MediaItem> mediaItems = new ArrayList<>(); List<MediaItem> mediaItems = new ArrayList<>();
for (AlbumID3 album : albums) { for (AlbumID3 album : albums) {
Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId()); Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize()));
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(album.getName()) .setTitle(album.getName())
@ -228,7 +217,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>(); List<MediaItem> mediaItems = new ArrayList<>();
for (AlbumID3 album : albums) { for (AlbumID3 album : albums) {
Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId()); Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize()));
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(album.getName()) .setTitle(album.getName())
@ -283,7 +272,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>(); List<MediaItem> mediaItems = new ArrayList<>();
for (ArtistID3 artist : artists) { for (ArtistID3 artist : artists) {
Uri artworkUri = AlbumArtContentProvider.contentUri(artist.getCoverArtId()); Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(artist.getCoverArtId(), Preferences.getImageSize()));
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(artist.getName()) .setTitle(artist.getName())
@ -408,7 +397,7 @@ public class AutomotiveRepository {
List<Child> children = response.body().getSubsonicResponse().getIndexes().getChildren(); List<Child> children = response.body().getSubsonicResponse().getIndexes().getChildren();
for (Child song : children) { for (Child song : children) {
Uri artworkUri = AlbumArtContentProvider.contentUri(song.getCoverArtId()); Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(song.getCoverArtId(), Preferences.getImageSize()));
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(song.getTitle()) .setTitle(song.getTitle())
@ -462,7 +451,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>(); List<MediaItem> mediaItems = new ArrayList<>();
for (Child child : directory.getChildren()) { for (Child child : directory.getChildren()) {
Uri artworkUri = AlbumArtContentProvider.contentUri(child.getCoverArtId()); Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(child.getCoverArtId(), Preferences.getImageSize()));
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(child.getTitle()) .setTitle(child.getTitle())
@ -561,7 +550,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>(); List<MediaItem> mediaItems = new ArrayList<>();
for (PodcastEpisode episode : episodes) { for (PodcastEpisode episode : episodes) {
Uri artworkUri = AlbumArtContentProvider.contentUri(episode.getCoverArtId()); Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(episode.getCoverArtId(), Preferences.getImageSize()));
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(episode.getTitle()) .setTitle(episode.getTitle())
@ -615,7 +604,20 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>(); List<MediaItem> mediaItems = new ArrayList<>();
for (InternetRadioStation radioStation : radioStations) { for (InternetRadioStation radioStation : radioStations) {
mediaItems.add(MappingUtil.mapInternetRadioStation(radioStation)); MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(radioStation.getName())
.setIsBrowsable(false)
.setIsPlayable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_RADIO_STATION)
.build();
MediaItem mediaItem = new MediaItem.Builder()
.setMediaId(radioStation.getId())
.setMediaMetadata(mediaMetadata)
.setUri(radioStation.getStreamUrl())
.build();
mediaItems.add(mediaItem);
} }
setInternetRadioStationsMetadata(radioStations); setInternetRadioStationsMetadata(radioStations);
@ -685,7 +687,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>(); List<MediaItem> mediaItems = new ArrayList<>();
for (AlbumID3 album : albums) { for (AlbumID3 album : albums) {
Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId()); Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize()));
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(album.getName()) .setTitle(album.getName())
@ -798,7 +800,7 @@ public class AutomotiveRepository {
if (response.body().getSubsonicResponse().getSearchResult3().getArtists() != null) { if (response.body().getSubsonicResponse().getSearchResult3().getArtists() != null) {
for (ArtistID3 artist : response.body().getSubsonicResponse().getSearchResult3().getArtists()) { for (ArtistID3 artist : response.body().getSubsonicResponse().getSearchResult3().getArtists()) {
Uri artworkUri = AlbumArtContentProvider.contentUri(artist.getCoverArtId()); Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(artist.getCoverArtId(), Preferences.getImageSize()));
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(artist.getName()) .setTitle(artist.getName())
@ -820,7 +822,7 @@ public class AutomotiveRepository {
if (response.body().getSubsonicResponse().getSearchResult3().getAlbums() != null) { if (response.body().getSubsonicResponse().getSearchResult3().getAlbums() != null) {
for (AlbumID3 album : response.body().getSubsonicResponse().getSearchResult3().getAlbums()) { for (AlbumID3 album : response.body().getSubsonicResponse().getSearchResult3().getAlbums()) {
Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId()); Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize()));
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(album.getName()) .setTitle(album.getName())

View file

@ -1,13 +1,13 @@
package com.cappielloantonio.tempo.repository; package com.cappielloantonio.tempo.repository;
import static android.provider.Settings.System.getString;
import android.provider.Settings;
import android.widget.Toast; import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.MutableLiveData;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.R;
@ -26,45 +26,8 @@ import retrofit2.Callback;
import retrofit2.Response; import retrofit2.Response;
public class PlaylistRepository { public class PlaylistRepository {
private static final MutableLiveData<Boolean> playlistUpdateTrigger = new MutableLiveData<>();
public LiveData<Boolean> getPlaylistUpdateTrigger() {
return playlistUpdateTrigger;
}
public void notifyPlaylistChanged() {
playlistUpdateTrigger.postValue(true);
refreshAllPlaylists();
}
@androidx.media3.common.util.UnstableApi @androidx.media3.common.util.UnstableApi
private final PlaylistDao playlistDao = AppDatabase.getInstance().playlistDao(); private final PlaylistDao playlistDao = AppDatabase.getInstance().playlistDao();
private static final MutableLiveData<List<Playlist>> allPlaylistsLiveData = new MutableLiveData<>();
public LiveData<List<Playlist>> getAllPlaylists(LifecycleOwner owner) {
refreshAllPlaylists();
return allPlaylistsLiveData;
}
public void refreshAllPlaylists() {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.getPlaylists()
.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().getPlaylists() != null) {
List<Playlist> playlists = response.body().getSubsonicResponse().getPlaylists().getPlaylists();
allPlaylistsLiveData.postValue(playlists);
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
}
public MutableLiveData<List<Playlist>> getPlaylists(boolean random, int size) { public MutableLiveData<List<Playlist>> getPlaylists(boolean random, int size) {
MutableLiveData<List<Playlist>> listLivePlaylists = new MutableLiveData<>(new ArrayList<>()); MutableLiveData<List<Playlist>> listLivePlaylists = new MutableLiveData<>(new ArrayList<>());
@ -144,62 +107,27 @@ public class PlaylistRepository {
return playlistLiveData; return playlistLiveData;
} }
public interface AddToPlaylistCallback { public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) {
void onSuccess();
void onFailure();
void onAllSkipped();
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId, Boolean playlistVisibilityIsPublic, AddToPlaylistCallback callback) {
android.util.Log.d("PlaylistRepository", "addSongToPlaylist: id=" + playlistId + ", songs=" + songsId);
if (songsId.isEmpty()) { if (songsId.isEmpty()) {
if (callback != null) callback.onAllSkipped(); Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show();
} else{ } else{
App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getPlaylistClient() .getPlaylistClient()
.updatePlaylist(playlistId, null, playlistVisibilityIsPublic, songsId, null) .updatePlaylist(playlistId, null, true, songsId, null)
.enqueue(new Callback<ApiResponse>() { .enqueue(new Callback<ApiResponse>() {
@Override @Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) notifyPlaylistChanged(); Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show();
if (callback != null) callback.onSuccess();
} }
@Override @Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) { public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
if (callback != null) callback.onFailure(); Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show();
} }
}); });
} }
} }
public void removeSongFromPlaylist(String playlistId, int index, AddToPlaylistCallback callback) {
ArrayList<Integer> indexes = new ArrayList<>();
indexes.add(index);
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.updatePlaylist(playlistId, null, true, null, indexes)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) notifyPlaylistChanged();
if (callback != null) {
if (response.isSuccessful()) callback.onSuccess();
else callback.onFailure();
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
if (callback != null) callback.onFailure();
}
});
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId, Boolean playlistVisibilityIsPublic) {
addSongToPlaylist(playlistId, songsId, playlistVisibilityIsPublic, null);
}
public void createPlaylist(String playlistId, String name, ArrayList<String> songsId) { public void createPlaylist(String playlistId, String name, ArrayList<String> songsId) {
App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getPlaylistClient() .getPlaylistClient()
@ -207,7 +135,7 @@ public class PlaylistRepository {
.enqueue(new Callback<ApiResponse>() { .enqueue(new Callback<ApiResponse>() {
@Override @Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) notifyPlaylistChanged();
} }
@Override @Override
@ -220,45 +148,20 @@ public class PlaylistRepository {
public void updatePlaylist(String playlistId, String name, ArrayList<String> songsId) { public void updatePlaylist(String playlistId, String name, ArrayList<String> songsId) {
App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getPlaylistClient() .getPlaylistClient()
.updatePlaylist(playlistId, name, true, null, null) .deletePlaylist(playlistId)
.enqueue(new Callback<ApiResponse>() { .enqueue(new Callback<ApiResponse>() {
@Override @Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) { createPlaylist(null, name, songsId);
// After renaming, we need to handle the song list update.
// Subsonic doesn't have a "replace all songs" in updatePlaylist.
// So we might still need to recreate if the songs changed significantly,
// but if we just renamed, we should update the local pinned database.
updateLocalPinnedPlaylistName(playlistId, name);
notifyPlaylistChanged();
}
// If songsId is provided, we might want to re-sync them.
// For now, let's at least fix the name duplication issue.
} }
@Override @Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) { public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
} }
}); });
} }
@OptIn(markerClass = UnstableApi.class)
private void updateLocalPinnedPlaylistName(String id, String newName) {
new Thread(() -> {
List<Playlist> pinned = playlistDao.getAllSync();
if (pinned != null) {
for (Playlist p : pinned) {
if (p.getId().equals(id)) {
p.setName(newName);
playlistDao.insert(p); // Replace strategy will update it
break;
}
}
}
}).start();
}
public void deletePlaylist(String playlistId) { public void deletePlaylist(String playlistId) {
App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getPlaylistClient() .getPlaylistClient()
@ -266,7 +169,7 @@ public class PlaylistRepository {
.enqueue(new Callback<ApiResponse>() { .enqueue(new Callback<ApiResponse>() {
@Override @Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) notifyPlaylistChanged();
} }
@Override @Override
@ -294,49 +197,6 @@ public class PlaylistRepository {
thread.start(); thread.start();
} }
@androidx.media3.common.util.UnstableApi
public void updatePinnedPlaylists() {
updatePinnedPlaylists(null);
}
@androidx.media3.common.util.UnstableApi
public void updatePinnedPlaylists(List<String> forceIds) {
new Thread(() -> {
List<Playlist> pinned = playlistDao.getAllSync();
if (pinned != null && !pinned.isEmpty()) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.getPlaylists()
.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().getPlaylists() != null) {
List<Playlist> remotes = response.body().getSubsonicResponse().getPlaylists().getPlaylists();
new Thread(() -> {
for (Playlist p : pinned) {
for (Playlist r : remotes) {
if (p.getId().equals(r.getId())) {
p.setName(r.getName());
p.setSongCount(r.getSongCount());
p.setDuration(r.getDuration());
p.setCoverArtId(r.getCoverArtId());
playlistDao.insert(p);
break;
}
}
}
}).start();
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
}
}).start();
}
private static class InsertThreadSafe implements Runnable { private static class InsertThreadSafe implements Runnable {
private final PlaylistDao playlistDao; private final PlaylistDao playlistDao;
private final Playlist playlist; private final Playlist playlist;

View file

@ -66,33 +66,88 @@ public class PodcastRepository {
return liveNewestPodcastEpisodes; return liveNewestPodcastEpisodes;
} }
public Call<ApiResponse> refreshPodcasts() { public void refreshPodcasts() {
return App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getPodcastClient() .getPodcastClient()
.refreshPodcasts(); .refreshPodcasts()
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
} }
public Call<ApiResponse> createPodcastChannel(String url) { @Override
return App.getSubsonicClientInstance(false) public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
.getPodcastClient()
.createPodcastChannel(url); }
});
} }
public Call<ApiResponse> deletePodcastChannel(String channelId) { public void createPodcastChannel(String url) {
return App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getPodcastClient() .getPodcastClient()
.deletePodcastChannel(channelId); .createPodcastChannel(url)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
} }
public Call<ApiResponse> deletePodcastEpisode(String episodeId) { @Override
return App.getSubsonicClientInstance(false) public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
.getPodcastClient()
.deletePodcastEpisode(episodeId); }
});
} }
public Call<ApiResponse> downloadPodcastEpisode(String episodeId) { public void deletePodcastChannel(String channelId) {
return App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getPodcastClient() .getPodcastClient()
.downloadPodcastEpisode(episodeId); .deletePodcastChannel(channelId)
.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 deletePodcastEpisode(String episodeId) {
App.getSubsonicClientInstance(false)
.getPodcastClient()
.deletePodcastEpisode(episodeId)
.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 downloadPodcastEpisode(String episodeId) {
App.getSubsonicClientInstance(false)
.getPodcastClient()
.downloadPodcastEpisode(episodeId)
.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) {
}
});
} }
} }

View file

@ -1,11 +1,8 @@
package com.cappielloantonio.tempo.repository; package com.cappielloantonio.tempo.repository;
import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.database.AppDatabase; import com.cappielloantonio.tempo.database.AppDatabase;
@ -55,8 +52,6 @@ public class QueueRepository {
public MutableLiveData<PlayQueue> getPlayQueue() { public MutableLiveData<PlayQueue> getPlayQueue() {
MutableLiveData<PlayQueue> playQueue = new MutableLiveData<>(); MutableLiveData<PlayQueue> playQueue = new MutableLiveData<>();
Log.d(TAG, "Getting play queue from server...");
App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getBookmarksClient() .getBookmarksClient()
.getPlayQueue() .getPlayQueue()
@ -64,19 +59,12 @@ public class QueueRepository {
@Override @Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlayQueue() != null) { if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getPlayQueue() != null) {
PlayQueue serverQueue = response.body().getSubsonicResponse().getPlayQueue(); playQueue.setValue(response.body().getSubsonicResponse().getPlayQueue());
Log.d(TAG, "Server returned play queue with " +
(serverQueue.getEntries() != null ? serverQueue.getEntries().size() : 0) + " items");
playQueue.setValue(serverQueue);
} else {
Log.d(TAG, "Server returned no play queue");
playQueue.setValue(null);
} }
} }
@Override @Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) { public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.e(TAG, "Failed to get play queue", t);
playQueue.setValue(null); playQueue.setValue(null);
} }
}); });
@ -85,24 +73,18 @@ public class QueueRepository {
} }
public void savePlayQueue(List<String> ids, String current, long position) { public void savePlayQueue(List<String> ids, String current, long position) {
Log.d(TAG, "Saving play queue to server - Items: " + ids.size() + ", Current: " + current);
App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getBookmarksClient() .getBookmarksClient()
.savePlayQueue(ids, current, position) .savePlayQueue(ids, current, position)
.enqueue(new Callback<ApiResponse>() { .enqueue(new Callback<ApiResponse>() {
@Override @Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) {
Log.d(TAG, "Play queue saved successfully");
} else {
Log.d(TAG, "Play queue save failed with code: " + response.code());
}
} }
@Override @Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) { public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.e(TAG, "Play queue save failed", t);
} }
}); });
} }
@ -141,6 +123,7 @@ public class QueueRepository {
private boolean isMediaInQueue(List<Queue> queue, Child media) { private boolean isMediaInQueue(List<Queue> queue, Child media) {
if (queue == null || media == null) return false; if (queue == null || media == null) return false;
return queue.stream().anyMatch(queueItem -> return queue.stream().anyMatch(queueItem ->
queueItem != null && media.getId() != null && queueItem != null && media.getId() != null &&
queueItem.getId().equals(media.getId()) queueItem.getId().equals(media.getId())

View file

@ -38,22 +38,54 @@ public class RadioRepository {
return radioStation; return radioStation;
} }
public Call<ApiResponse> createInternetRadioStation(String name, String streamURL, String homepageURL) { public void createInternetRadioStation(String name, String streamURL, String homepageURL) {
return App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getInternetRadioClient() .getInternetRadioClient()
.createInternetRadioStation(streamURL, name, homepageURL); .createInternetRadioStation(streamURL, name, homepageURL)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
} }
public Call<ApiResponse> updateInternetRadioStation(String id, String name, String streamURL, String homepageURL) { @Override
return App.getSubsonicClientInstance(false) public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
.getInternetRadioClient()
.updateInternetRadioStation(id, streamURL, name, homepageURL); }
});
} }
public Call<ApiResponse> deleteInternetRadioStation(String id) { public void updateInternetRadioStation(String id, String name, String streamURL, String homepageURL) {
return App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getInternetRadioClient() .getInternetRadioClient()
.deleteInternetRadioStation(id); .updateInternetRadioStation(id, streamURL, name, homepageURL)
.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 deleteInternetRadioStation(String id) {
App.getSubsonicClientInstance(false)
.getInternetRadioClient()
.deleteInternetRadioStation(id)
.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) {
}
});
}
} }

View file

@ -13,7 +13,6 @@ import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.SearchResult2; import com.cappielloantonio.tempo.subsonic.models.SearchResult2;
import com.cappielloantonio.tempo.subsonic.models.SearchResult3; import com.cappielloantonio.tempo.subsonic.models.SearchResult3;
import com.cappielloantonio.tempo.util.Preferences;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
@ -187,13 +186,8 @@ public class SearchingRepository {
@Override @Override
public void run() { public void run() {
if(Preferences.isSearchSortingChronologicallyEnabled()){
recent = recentSearchDao.getRecent(); recent = recentSearchDao.getRecent();
} }
else {
recent = recentSearchDao.getAlpha();
}
}
public List<String> getRecent() { public List<String> getRecent() {
return recent; return recent;

View file

@ -41,7 +41,7 @@ public class SharingRepository {
public MutableLiveData<Share> createShare(String id, String description, Long expires) { public MutableLiveData<Share> createShare(String id, String description, Long expires) {
MutableLiveData<Share> share = new MutableLiveData<>(); MutableLiveData<Share> share = new MutableLiveData<>();
App.getSubsonicPublicClientInstance(false) App.getSubsonicClientInstance(false)
.getSharingClient() .getSharingClient()
.createShare(id, description, expires) .createShare(id, description, expires)
.enqueue(new Callback<ApiResponse>() { .enqueue(new Callback<ApiResponse>() {
@ -64,7 +64,7 @@ public class SharingRepository {
} }
public void updateShare(String id, String description, Long expires) { public void updateShare(String id, String description, Long expires) {
App.getSubsonicPublicClientInstance(false) App.getSubsonicClientInstance(false)
.getSharingClient() .getSharingClient()
.updateShare(id, description, expires) .updateShare(id, description, expires)
.enqueue(new Callback<ApiResponse>() { .enqueue(new Callback<ApiResponse>() {

View file

@ -1,35 +1,23 @@
package com.cappielloantonio.tempo.repository; package com.cappielloantonio.tempo.repository;
import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.subsonic.base.ApiResponse; import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.SubsonicResponse;
import com.cappielloantonio.tempo.util.Constants.SeedType;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Set;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.Callback; import retrofit2.Callback;
import retrofit2.Response; import retrofit2.Response;
public class SongRepository { public class SongRepository {
private static final String TAG = "SongRepository"; private static final String TAG = "SongRepository";
public interface MediaCallbackInternal {
void onSongsAvailable(List<Child> songs);
}
public MutableLiveData<List<Child>> getStarredSongs(boolean random, int size) { public MutableLiveData<List<Child>> getStarredSongs(boolean random, int size) {
MutableLiveData<List<Child>> starredSongs = new MutableLiveData<>(Collections.emptyList()); MutableLiveData<List<Child>> starredSongs = new MutableLiveData<>(Collections.emptyList());
@ -54,202 +42,25 @@ public class SongRepository {
} }
@Override @Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {} public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
}); });
return starredSongs; return starredSongs;
} }
/** public MutableLiveData<List<Child>> getInstantMix(String id, int count) {
* Used by ViewModels. Updates the LiveData list incrementally as songs are found.
*/
public MutableLiveData<List<Child>> getInstantMix(String id, SeedType type, int count) {
MutableLiveData<List<Child>> instantMix = new MutableLiveData<>(new ArrayList<>());
Set<String> trackIds = new HashSet<>();
performSmartMix(id, type, count, songs -> {
List<Child> current = instantMix.getValue();
if (current != null) {
for (Child s : songs) {
if (!trackIds.contains(s.getId())) {
current.add(s);
trackIds.add(s.getId());
}
}
if (current.size() < count / 2) {
fetchSimilarOnly(id, count, remainder -> {
for (Child r : remainder) {
if (!trackIds.contains(r.getId())) {
current.add(r);
trackIds.add(r.getId());
}
}
instantMix.postValue(current);
});
} else {
instantMix.postValue(current);
}
}
});
return instantMix;
}
/**
* Overloaded method used by other Repositories
*/
public void getInstantMix(String id, SeedType type, int count, MediaCallbackInternal callback) {
new MediaCallbackAccumulator(callback, count).start(id, type);
}
private class MediaCallbackAccumulator {
private final MediaCallbackInternal originalCallback;
private final int targetCount;
private final List<Child> accumulatedSongs = new ArrayList<>();
private final Set<String> trackIds = new HashSet<>();
private boolean isComplete = false;
MediaCallbackAccumulator(MediaCallbackInternal callback, int count) {
this.originalCallback = callback;
this.targetCount = count;
}
void start(String id, SeedType type) {
performSmartMix(id, type, targetCount, this::onBatchReceived);
}
private void onBatchReceived(List<Child> batch) {
if (isComplete || batch == null || batch.isEmpty()) {
return;
}
int added = 0;
for (Child song : batch) {
if (!trackIds.contains(song.getId()) && accumulatedSongs.size() < targetCount) {
trackIds.add(song.getId());
accumulatedSongs.add(song);
added++;
}
}
if (accumulatedSongs.size() >= targetCount) {
originalCallback.onSongsAvailable(new ArrayList<>(accumulatedSongs));
isComplete = true;
}
}
}
private void performSmartMix(final String id, final SeedType type, final int count, final MediaCallbackInternal callback) {
switch (type) {
case ARTIST:
fetchSimilarByArtist(id, count, callback);
break;
case ALBUM:
fetchAlbumSongs(id, count, callback);
break;
case TRACK:
fetchSingleTrackThenSimilar(id, count, callback);
break;
}
}
private void fetchAlbumSongs(String albumId, int count, MediaCallbackInternal callback) {
App.getSubsonicClientInstance(false).getBrowsingClient().getAlbum(albumId).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().getAlbum() != null) {
List<Child> albumSongs = response.body().getSubsonicResponse().getAlbum().getSongs();
if (albumSongs != null && !albumSongs.isEmpty()) {
int fromAlbum = Math.min(count, albumSongs.size());
List<Child> limitedAlbumSongs = albumSongs.subList(0, fromAlbum);
callback.onSongsAvailable(new ArrayList<>(limitedAlbumSongs));
}
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.e(TAG, "fetchAlbumSongsThenSimilar.onFailure()", t);
}
});
}
private void fetchSimilarByArtist(String artistId, final int count, final MediaCallbackInternal callback) {
App.getSubsonicClientInstance(false)
.getBrowsingClient()
.getSimilarSongs2(artistId, count)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> similar = extractSongs(response, "similarSongs2");
Log.d(TAG, "fetchSimilarByArtist.onResponse() - similar songs: " + similar.size());
if (!similar.isEmpty()) {
List<Child> limitedSimilar = similar.subList(0, Math.min(count, similar.size()));
callback.onSongsAvailable(limitedSimilar);
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.e(TAG, "fetchSimilarByArtist.onFailure()", t);
}
});
}
private void fetchSingleTrackThenSimilar(String trackId, int count, MediaCallbackInternal callback) {
App.getSubsonicClientInstance(false).getBrowsingClient().getSong(trackId).enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null) {
Child song = response.body().getSubsonicResponse().getSong();
if (song != null) {
callback.onSongsAvailable(Collections.singletonList(song));
}
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.e(TAG, "fetchSingleTrackThenSimilar.onFailure()", t);
}
});
}
private void fetchSimilarOnly(String id, int count, MediaCallbackInternal callback) {
App.getSubsonicClientInstance(false).getBrowsingClient().getSimilarSongs(id, count).enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> songs = extractSongs(response, "similarSongs");
if (!songs.isEmpty()) {
int limit = Math.min(count, songs.size());
callback.onSongsAvailable(songs.subList(0, limit));
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.e(TAG, "fetchSimilarOnly.onFailure()", t);
}
});
}
public MutableLiveData<List<Child>> getContinuousMix(String id, int count) {
MutableLiveData<List<Child>> instantMix = new MutableLiveData<>(); MutableLiveData<List<Child>> instantMix = new MutableLiveData<>();
App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getBrowsingClient() .getBrowsingClient()
.getSimilarSongs(id, count) .getSimilarSongs2(id, count)
.enqueue(new Callback<ApiResponse>() { .enqueue(new Callback<ApiResponse>() {
@Override @Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSimilarSongs() != null) { if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSimilarSongs2() != null) {
instantMix.setValue(response.body().getSubsonicResponse().getSimilarSongs().getSongs()); instantMix.setValue(response.body().getSubsonicResponse().getSimilarSongs2().getSongs());
} }
} }
@ -262,128 +73,188 @@ public class SongRepository {
return instantMix; return instantMix;
} }
private List<Child> extractSongs(Response<ApiResponse> response, String type) {
if (response.isSuccessful() && response.body() != null) {
SubsonicResponse res = response.body().getSubsonicResponse();
List<Child> list = null;
if (type.equals("similarSongs") && res.getSimilarSongs() != null) {
list = res.getSimilarSongs().getSongs();
} else if (type.equals("similarSongs2") && res.getSimilarSongs2() != null) {
list = res.getSimilarSongs2().getSongs();
}
return (list != null) ? list : new ArrayList<>();
}
return new ArrayList<>();
}
public MutableLiveData<List<Child>> getRandomSample(int number, Integer fromYear, Integer toYear) { public MutableLiveData<List<Child>> getRandomSample(int number, Integer fromYear, Integer toYear) {
MutableLiveData<List<Child>> randomSongsSample = new MutableLiveData<>(); MutableLiveData<List<Child>> randomSongsSample = new MutableLiveData<>();
App.getSubsonicClientInstance(false).getAlbumSongListClient().getRandomSongs(number, fromYear, toYear).enqueue(new Callback<ApiResponse>() {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { App.getSubsonicClientInstance(false)
.getAlbumSongListClient()
.getRandomSongs(number, fromYear, toYear)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> songs = new ArrayList<>(); List<Child> songs = new ArrayList<>();
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null) {
List<Child> returned = response.body().getSubsonicResponse().getRandomSongs().getSongs(); if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null && response.body().getSubsonicResponse().getRandomSongs().getSongs() != null) {
if (returned != null) { songs.addAll(response.body().getSubsonicResponse().getRandomSongs().getSongs());
songs.addAll(returned);
}
} }
randomSongsSample.setValue(songs); randomSongsSample.setValue(songs);
} }
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
}); });
return randomSongsSample; return randomSongsSample;
} }
public MutableLiveData<List<Child>> getRandomSampleWithGenre(int number, Integer fromYear, Integer toYear, String genre) { public MutableLiveData<List<Child>> getRandomSampleWithGenre(int number, Integer fromYear, Integer toYear, String genre) {
MutableLiveData<List<Child>> randomSongsSample = new MutableLiveData<>(); MutableLiveData<List<Child>> randomSongsSample = new MutableLiveData<>();
App.getSubsonicClientInstance(false).getAlbumSongListClient().getRandomSongs(number, fromYear, toYear, genre).enqueue(new Callback<ApiResponse>() { App.getSubsonicClientInstance(false)
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { .getAlbumSongListClient()
.getRandomSongs(number, fromYear, toYear, genre)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> songs = new ArrayList<>(); List<Child> songs = new ArrayList<>();
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null) {
List<Child> returned = response.body().getSubsonicResponse().getRandomSongs().getSongs(); if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null && response.body().getSubsonicResponse().getRandomSongs().getSongs() != null) {
if (returned != null) { songs.addAll(response.body().getSubsonicResponse().getRandomSongs().getSongs());
songs.addAll(returned);
}
} }
randomSongsSample.setValue(songs); randomSongsSample.setValue(songs);
} }
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
}); });
return randomSongsSample; return randomSongsSample;
} }
public void scrobble(String id, boolean submission) { public void scrobble(String id, boolean submission) {
App.getSubsonicClientInstance(false).getMediaAnnotationClient().scrobble(id, submission).enqueue(new Callback<ApiResponse>() { App.getSubsonicClientInstance(false)
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {} .getMediaAnnotationClient()
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {} .scrobble(id, submission)
.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 setRating(String id, int rating) { public void setRating(String id, int rating) {
App.getSubsonicClientInstance(false).getMediaAnnotationClient().setRating(id, rating).enqueue(new Callback<ApiResponse>() { App.getSubsonicClientInstance(false)
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {} .getMediaAnnotationClient()
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {} .setRating(id, rating)
.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 MutableLiveData<List<Child>> getSongsByGenre(String id, int page) { public MutableLiveData<List<Child>> getSongsByGenre(String id, int page) {
MutableLiveData<List<Child>> songsByGenre = new MutableLiveData<>(); MutableLiveData<List<Child>> songsByGenre = new MutableLiveData<>();
App.getSubsonicClientInstance(false).getAlbumSongListClient().getSongsByGenre(id, 100, 100 * page).enqueue(new Callback<ApiResponse>() {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { App.getSubsonicClientInstance(false)
.getAlbumSongListClient()
.getSongsByGenre(id, 100, 100 * page)
.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().getSongsByGenre() != null) { if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) {
songsByGenre.setValue(response.body().getSubsonicResponse().getSongsByGenre().getSongs()); songsByGenre.setValue(response.body().getSubsonicResponse().getSongsByGenre().getSongs());
} }
} }
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
}); });
return songsByGenre; return songsByGenre;
} }
public MutableLiveData<List<Child>> getSongsByGenres(ArrayList<String> genresId) { public MutableLiveData<List<Child>> getSongsByGenres(ArrayList<String> genresId) {
MutableLiveData<List<Child>> songsByGenre = new MutableLiveData<>(); MutableLiveData<List<Child>> songsByGenre = new MutableLiveData<>();
for (String id : genresId) {
App.getSubsonicClientInstance(false).getAlbumSongListClient().getSongsByGenre(id, 500, 0).enqueue(new Callback<ApiResponse>() { for (String id : genresId)
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { App.getSubsonicClientInstance(false)
.getAlbumSongListClient()
.getSongsByGenre(id, 500, 0)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> songs = new ArrayList<>(); List<Child> songs = new ArrayList<>();
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) { if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) {
List<Child> returned = response.body().getSubsonicResponse().getSongsByGenre().getSongs(); songs.addAll(response.body().getSubsonicResponse().getSongsByGenre().getSongs());
if (returned != null) {
songs.addAll(returned);
}
} }
songsByGenre.setValue(songs); songsByGenre.setValue(songs);
} }
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
}); @Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
} }
});
return songsByGenre; return songsByGenre;
} }
public MutableLiveData<Child> getSong(String id) { public MutableLiveData<Child> getSong(String id) {
MutableLiveData<Child> song = new MutableLiveData<>(); MutableLiveData<Child> song = new MutableLiveData<>();
App.getSubsonicClientInstance(false).getBrowsingClient().getSong(id).enqueue(new Callback<ApiResponse>() {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { App.getSubsonicClientInstance(false)
.getBrowsingClient()
.getSong(id)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null) { if (response.isSuccessful() && response.body() != null) {
song.setValue(response.body().getSubsonicResponse().getSong()); song.setValue(response.body().getSubsonicResponse().getSong());
} }
} }
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
}); });
return song; return song;
} }
public MutableLiveData<String> getSongLyrics(Child song) { public MutableLiveData<String> getSongLyrics(Child song) {
MutableLiveData<String> lyrics = new MutableLiveData<>(null); MutableLiveData<String> lyrics = new MutableLiveData<>(null);
App.getSubsonicClientInstance(false).getMediaRetrievalClient().getLyrics(song.getArtist(), song.getTitle()).enqueue(new Callback<ApiResponse>() {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { App.getSubsonicClientInstance(false)
.getMediaRetrievalClient()
.getLyrics(song.getArtist(), song.getTitle())
.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().getLyrics() != null) { if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getLyrics() != null) {
lyrics.setValue(response.body().getSubsonicResponse().getLyrics().getValue()); lyrics.setValue(response.body().getSubsonicResponse().getLyrics().getValue());
} }
} }
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
}); });
return lyrics; return lyrics;
} }
} }

View file

@ -1,864 +0,0 @@
package com.cappielloantonio.tempo.service
import android.annotation.SuppressLint
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.TaskStackBuilder
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.os.Binder
import android.os.Bundle
import android.os.IBinder
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.media3.common.*
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder
import androidx.media3.session.*
import androidx.media3.session.MediaSession.ControllerInfo
import androidx.media3.extractor.metadata.icy.IcyInfo
import androidx.media3.extractor.metadata.id3.TextInformationFrame
import androidx.media3.extractor.metadata.vorbis.VorbisComment
import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.repository.QueueRepository
import com.cappielloantonio.tempo.ui.activity.MainActivity
import com.cappielloantonio.tempo.util.*
import com.cappielloantonio.tempo.widget.WidgetUpdateManager
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
private const val TAG = "BaseMediaService"
@UnstableApi
open class BaseMediaService : MediaLibraryService() {
companion object {
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
"android.media3.session.demo.SHUFFLE_ON"
private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF =
"android.media3.session.demo.SHUFFLE_OFF"
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF =
"android.media3.session.demo.REPEAT_OFF"
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE =
"android.media3.session.demo.REPEAT_ONE"
private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL =
"android.media3.session.demo.REPEAT_ALL"
const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER"
const val ACTION_EQUALIZER_UPDATED = "com.cappielloantonio.tempo.service.EQUALIZER_UPDATED"
}
protected lateinit var exoplayer: ExoPlayer
protected lateinit var mediaLibrarySession: MediaLibrarySession
private lateinit var networkCallback: CustomNetworkCallback
private lateinit var equalizerManager: EqualizerManager
private val widgetUpdateHandler = Handler(Looper.getMainLooper())
private var widgetUpdateScheduled = false
private val widgetUpdateRunnable = object : Runnable {
override fun run() {
val player = mediaLibrarySession.player
if (!player.isPlaying) {
widgetUpdateScheduled = false
return
}
updateWidget(player)
widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
}
}
private val radioHeaderCheckExecutor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
private var radioHeaderCheckScheduled = false
private var radioHeaderCheckFuture: ScheduledFuture<*>? = null
private val radioHeaderCheckRunnable = Runnable {
checkRadioHttpHeaders()
}
private val binder = LocalBinder()
open fun playerInitHook() {
initializeExoPlayer()
initializeMediaLibrarySession(exoplayer)
initializePlayerListener(exoplayer)
setPlayer(null, exoplayer)
}
open fun getMediaLibrarySessionCallback(): MediaLibrarySession.Callback {
return CustomMediaLibrarySessionCallback(baseContext)
}
fun updateMediaItems(player: Player) {
Log.d(TAG, "update items")
val n = player.mediaItemCount
val k = player.currentMediaItemIndex
val current = player.currentPosition
val items = (0..n - 1).map { MappingUtil.mapMediaItem(player.getMediaItemAt(it)) }
player.clearMediaItems()
player.setMediaItems(items, k, current)
}
fun restorePlayerFromQueue(player: Player) {
if (player.mediaItemCount > 0) return
val queueRepository = QueueRepository()
val storedQueue = queueRepository.media
if (storedQueue.isNullOrEmpty()) return
val mediaItems = MappingUtil.mapMediaItems(storedQueue)
if (mediaItems.isEmpty()) return
val lastIndex = try {
queueRepository.lastPlayedMediaIndex
} catch (_: Exception) {
0
}.coerceIn(0, mediaItems.size - 1)
val lastPosition = try {
queueRepository.lastPlayedMediaTimestamp
} catch (_: Exception) {
0L
}.let { if (it < 0L) 0L else it }
player.setMediaItems(mediaItems, lastIndex, lastPosition)
player.prepare()
updateWidget(player)
}
private var lastRadioArtist: String? = null
private var lastRadioTitle: String? = null
fun initializePlayerListener(player: Player) {
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
Log.d(TAG, "onMediaItemTransition" + player.currentMediaItemIndex)
if (mediaItem == null) return
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem)
}
// Restart header checks for radio streams when media item changes
val mediaType = mediaItem.mediaMetadata.extras?.getString("type")
if (mediaType == Constants.MEDIA_TYPE_RADIO && player.isPlaying) {
stopRadioHeaderChecks()
scheduleRadioHeaderChecks()
} else if (mediaType != Constants.MEDIA_TYPE_RADIO) {
stopRadioHeaderChecks()
}
updateWidget(player)
}
override fun onTracksChanged(tracks: Tracks) {
Log.d(TAG, "onTracksChanged " + player.currentMediaItemIndex)
ReplayGainUtil.setReplayGain(player, tracks)
val currentMediaItem = player.currentMediaItem
if (currentMediaItem != null) {
val item = MappingUtil.mapMediaItem(currentMediaItem)
if (item.mediaMetadata.extras != null)
MediaManager.scrobble(item, false)
if (player.nextMediaItemIndex == C.INDEX_UNSET) {
val browserFuture = MediaBrowser.Builder(
this@BaseMediaService,
SessionToken(this@BaseMediaService, ComponentName(this@BaseMediaService, this@BaseMediaService::class.java))
).buildAsync()
MediaManager.continuousPlay(player.currentMediaItem, browserFuture)
}
}
if (player is ExoPlayer) {
// https://stackoverflow.com/questions/56937283/exoplayer-shuffle-doesnt-reproduce-all-the-songs
if (MediaManager.justStarted.get()) {
Log.d(TAG, "update shuffle order")
MediaManager.justStarted.set(false)
val shuffledList = IntArray(player.mediaItemCount) { i -> i }
shuffledList.shuffle()
val index = shuffledList.indexOf(player.currentMediaItemIndex)
// swap current media index to the first index
if (index > -1 && shuffledList.isNotEmpty()) {
val tmp = shuffledList[0]
shuffledList[0] = shuffledList[index]
shuffledList[index] = tmp
}
player.shuffleOrder =
DefaultShuffleOrder(shuffledList, kotlin.random.Random.nextLong())
}
}
}
override fun onMetadata(metadata: Metadata) {
// Handle streaming metadata (ICY, ID3) for radio / streaming content
val currentItem = player.currentMediaItem ?: return
val extras = currentItem.mediaMetadata.extras
if (extras?.getString("type") != Constants.MEDIA_TYPE_RADIO) return
var artist: String? = null
var title: String? = null
// Extract metadata from ICY/ID3/Vorbis
for (i in 0 until metadata.length()) {
when (val entry = metadata[i]) {
is IcyInfo -> {
entry.title?.let { icyTitle ->
val parts = icyTitle.split(" - ", limit = 2)
if (parts.size == 2) {
artist = parts[0].trim().ifEmpty { null }
title = parts[1].trim().ifEmpty { null }
} else {
title = icyTitle.trim().ifEmpty { null }
}
}
}
is TextInformationFrame -> {
@Suppress("DEPRECATION")
val value = entry.value
when (entry.id) {
"TPE1" -> if (!value.isNullOrBlank()) artist = value
"TIT2" -> if (!value.isNullOrBlank()) title = value
}
}
is VorbisComment -> {
@Suppress("DEPRECATION")
val value = entry.value
when (entry.key) {
"ARTIST" -> if (!value.isNullOrBlank()) artist = value
"TITLE" -> if (!value.isNullOrBlank()) title = value
}
}
}
}
if (artist.isNullOrBlank() && title.isNullOrBlank()) return
if (artist == lastRadioArtist && title == lastRadioTitle) return // Deduplicate
lastRadioArtist = artist
lastRadioTitle = title
// Stop HTTP header checks since we have embedded metadata
stopRadioHeaderChecks()
val currentIndex = player.currentMediaItemIndex
if (currentIndex == C.INDEX_UNSET) return
val metadataBuilder = currentItem.mediaMetadata.buildUpon()
val newExtras = Bundle(extras ?: Bundle())
// Store individual values in extras for UI
artist?.let { newExtras.putString("radioArtist", it) }
title?.let { newExtras.putString("radioTitle", it) }
// Get station name (preserve if already set)
val stationName = extras?.getString("stationName")
?: currentItem.mediaMetadata.title?.toString()
?: ""
if (stationName.isNotBlank()) {
newExtras.putString("stationName", stationName)
}
// Format for notification/player: Title = "Artist - Song", Artist = "Station Name"
val formattedTitle = when {
!artist.isNullOrBlank() && !title.isNullOrBlank() -> "$artist - $title"
!title.isNullOrBlank() -> title
!artist.isNullOrBlank() -> artist
else -> stationName
}
metadataBuilder.setTitle(formattedTitle)
if (stationName.isNotBlank()) {
metadataBuilder.setArtist(stationName)
}
(player as? ExoPlayer)?.let { exo ->
exo.replaceMediaItem(currentIndex, currentItem.buildUpon()
.setMediaMetadata(metadataBuilder.setExtras(newExtras).build())
.build())
updateWidget(exo)
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.d(TAG, "onIsPlayingChanged " + player.currentMediaItemIndex)
if (!isPlaying) {
MediaManager.setPlayingPausedTimestamp(
player.currentMediaItem,
player.currentPosition
)
} else {
MediaManager.scrobble(player.currentMediaItem, false)
}
if (isPlaying) {
scheduleWidgetUpdates()
scheduleRadioHeaderChecks()
} else {
stopWidgetUpdates()
stopRadioHeaderChecks()
}
updateWidget(player)
}
override fun onPlaybackStateChanged(playbackState: Int) {
Log.d(TAG, "onPlaybackStateChanged")
super.onPlaybackStateChanged(playbackState)
if (!player.hasNextMediaItem() &&
playbackState == Player.STATE_ENDED &&
player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC
) {
MediaManager.scrobble(player.currentMediaItem, true)
MediaManager.saveChronology(player.currentMediaItem)
}
updateWidget(player)
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
Log.d(TAG, "onPositionDiscontinuity")
super.onPositionDiscontinuity(oldPosition, newPosition, reason)
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) {
if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
MediaManager.scrobble(oldPosition.mediaItem, true)
MediaManager.saveChronology(oldPosition.mediaItem)
}
if (newPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) {
MediaManager.setLastPlayedTimestamp(newPosition.mediaItem)
}
}
}
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
}
override fun onRepeatModeChanged(repeatMode: Int) {
Preferences.setRepeatMode(repeatMode)
}
override fun onAudioSessionIdChanged(audioSessionId: Int) {
Log.d(TAG, "onAudioSessionIdChanged")
attachEqualizerIfPossible(audioSessionId)
}
})
if (player.isPlaying) {
scheduleWidgetUpdates()
}
}
fun setPlayer(oldPlayer: Player?, newPlayer: Player) {
if (oldPlayer === newPlayer) return
if (oldPlayer != null) {
val currentQueue = getQueueFromPlayer(oldPlayer)
val currentIndex = oldPlayer.currentMediaItemIndex
val currentPosition = oldPlayer.currentPosition
val isPlaying = oldPlayer.playWhenReady
oldPlayer.stop()
newPlayer.setMediaItems(currentQueue, currentIndex, currentPosition)
newPlayer.playWhenReady = isPlaying
newPlayer.prepare()
}
mediaLibrarySession.player = newPlayer
}
open fun releasePlayers() {
exoplayer.release()
}
fun getQueueFromPlayer(player: Player): List<MediaItem> {
return (0..player.mediaItemCount - 1).map(player::getMediaItemAt)
}
override fun onTaskRemoved(rootIntent: Intent?) {
val player = mediaLibrarySession.player
if (!player.playWhenReady || player.mediaItemCount == 0) {
stopSelf()
}
}
override fun onCreate() {
super.onCreate()
playerInitHook()
initializeEqualizerManager()
initializeNetworkListener()
restorePlayerFromQueue(mediaLibrarySession.player)
}
override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
return mediaLibrarySession
}
override fun onDestroy() {
releaseNetworkCallback()
equalizerManager.release()
stopWidgetUpdates()
stopRadioHeaderChecks()
radioHeaderCheckExecutor.shutdown()
releasePlayers()
mediaLibrarySession.release()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? {
// Check if the intent is for our custom equalizer binder
if (intent?.action == ACTION_BIND_EQUALIZER) {
return binder
}
// Otherwise, handle it as a normal MediaLibraryService connection
return super.onBind(intent)
}
private fun initializeExoPlayer() {
exoplayer = ExoPlayer.Builder(this)
.setRenderersFactory(getRenderersFactory())
.setMediaSourceFactory(getMediaSourceFactory())
.setAudioAttributes(AudioAttributes.DEFAULT, true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
.setLoadControl(initializeLoadControl())
.build()
exoplayer.shuffleModeEnabled = Preferences.isShuffleModeEnabled()
exoplayer.repeatMode = Preferences.getRepeatMode()
}
private fun initializeEqualizerManager() {
equalizerManager = EqualizerManager()
val audioSessionId = exoplayer.audioSessionId
attachEqualizerIfPossible(audioSessionId)
}
private fun initializeMediaLibrarySession(player: Player) {
Log.d(TAG, "initializeMediaLibrarySession")
val sessionActivityPendingIntent =
TaskStackBuilder.create(this).run {
addNextIntent(Intent(baseContext, MainActivity::class.java))
getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT)
}
mediaLibrarySession =
MediaLibrarySession.Builder(this, player, getMediaLibrarySessionCallback())
.setSessionActivity(sessionActivityPendingIntent)
.build()
}
private fun initializeNetworkListener() {
networkCallback = CustomNetworkCallback()
getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(
networkCallback
)
updateMediaItems(mediaLibrarySession.player)
}
private fun initializeLoadControl(): DefaultLoadControl {
return DefaultLoadControl.Builder()
.setBufferDurationsMs(
(DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
(DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(),
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
.build()
}
private fun releaseNetworkCallback() {
getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback)
}
private fun updateWidget(player: Player) {
val mi = player.currentMediaItem
val title = mi?.mediaMetadata?.title?.toString()
?: mi?.mediaMetadata?.extras?.getString("title")
val artist = mi?.mediaMetadata?.artist?.toString()
?: mi?.mediaMetadata?.extras?.getString("artist")
val album = mi?.mediaMetadata?.albumTitle?.toString()
?: mi?.mediaMetadata?.extras?.getString("album")
val extras = mi?.mediaMetadata?.extras
val coverId = extras?.getString("coverArtId")
val songLink = extras?.getString("assetLinkSong")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id"))
val albumLink = extras?.getString("assetLinkAlbum")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId"))
val artistLink = extras?.getString("assetLinkArtist")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId"))
val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
WidgetUpdateManager.updateFromState(
this,
title ?: "",
artist ?: "",
album ?: "",
coverId,
player.isPlaying,
player.shuffleModeEnabled,
player.repeatMode,
position,
duration,
songLink,
albumLink,
artistLink
)
}
private fun scheduleWidgetUpdates() {
if (widgetUpdateScheduled) return
widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS)
widgetUpdateScheduled = true
}
private fun stopWidgetUpdates() {
if (!widgetUpdateScheduled) return
widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable)
widgetUpdateScheduled = false
}
private fun scheduleRadioHeaderChecks() {
val player = mediaLibrarySession.player
val currentItem = player.currentMediaItem ?: return
val mediaType = currentItem.mediaMetadata.extras?.getString("type")
if (mediaType != Constants.MEDIA_TYPE_RADIO) return
if (radioHeaderCheckScheduled) return
// Check immediately, then periodically
checkRadioHttpHeaders()
radioHeaderCheckFuture = radioHeaderCheckExecutor.scheduleWithFixedDelay(
radioHeaderCheckRunnable,
RADIO_HEADER_CHECK_INTERVAL_SECONDS,
RADIO_HEADER_CHECK_INTERVAL_SECONDS,
TimeUnit.SECONDS
)
radioHeaderCheckScheduled = true
}
private fun stopRadioHeaderChecks() {
if (!radioHeaderCheckScheduled) return
radioHeaderCheckFuture?.cancel(false)
radioHeaderCheckFuture = null
radioHeaderCheckScheduled = false
}
private fun checkRadioHttpHeaders() {
val player = mediaLibrarySession.player
val currentItem = player.currentMediaItem ?: return
val extras = currentItem.mediaMetadata.extras
val mediaType = extras?.getString("type")
if (mediaType != Constants.MEDIA_TYPE_RADIO) return
// Skip if we already have embedded metadata (ICY/ID3) - HTTP headers are only fallback
val hasEmbeddedMetadata = !currentItem.mediaMetadata.artist.isNullOrBlank() ||
!currentItem.mediaMetadata.title.isNullOrBlank() ||
(extras != null && !extras.getString("radioArtist").isNullOrBlank()) ||
(extras != null && !extras.getString("radioTitle").isNullOrBlank())
if (hasEmbeddedMetadata) return
val streamUrl = extras?.getString("uri") ?: currentItem.requestMetadata.mediaUri?.toString()
if (streamUrl.isNullOrBlank()) return
try {
val url = URL(streamUrl)
val connection = url.openConnection() as? HttpURLConnection ?: return
// Only try HEAD request (lightweight) - skip GET fallback as it's unreliable
connection.requestMethod = "HEAD"
connection.setRequestProperty("Icy-MetaData", "1")
connection.setRequestProperty("User-Agent", "Tempus/1.0")
connection.connectTimeout = 3000 // Reduced timeout
connection.readTimeout = 3000
connection.connect()
if (connection.responseCode >= 400) {
connection.disconnect()
return
}
// Check for metadata in HTTP headers
val streamTitle = connection.getHeaderField("icy-name")
?: connection.getHeaderField("StreamTitle")
?: connection.getHeaderField("stream-title")
connection.disconnect()
if (!streamTitle.isNullOrBlank()) {
processStreamTitle(streamTitle, player)
}
} catch (e: Exception) {
// Silently fail - this is a fallback mechanism, ICY metadata is primary
}
}
private fun processStreamTitle(streamTitle: String, player: Player) {
// Parse "Artist - Title" format
val parts = streamTitle.split(" - ", limit = 2)
val artist = if (parts.size == 2) parts[0].trim().ifEmpty { null } else null
val title = if (parts.size == 2) parts[1].trim().ifEmpty { null } else streamTitle.trim().ifEmpty { null }
if (artist.isNullOrBlank() && title.isNullOrBlank()) return
if (artist == lastRadioArtist && title == lastRadioTitle) return // Deduplicate
lastRadioArtist = artist
lastRadioTitle = title
// Update on main thread
widgetUpdateHandler.post {
val currentItemNow = player.currentMediaItem ?: return@post
val currentIndex = player.currentMediaItemIndex
if (currentIndex == C.INDEX_UNSET) return@post
val currentExtras = currentItemNow.mediaMetadata.extras
if (currentExtras?.getString("type") != Constants.MEDIA_TYPE_RADIO) return@post
// Double-check we still don't have embedded metadata (might have arrived since check)
val hasEmbeddedMetadata = !currentItemNow.mediaMetadata.artist.isNullOrBlank() ||
!currentItemNow.mediaMetadata.title.isNullOrBlank() ||
(currentExtras != null && !currentExtras.getString("radioArtist").isNullOrBlank()) ||
(currentExtras != null && !currentExtras.getString("radioTitle").isNullOrBlank())
if (hasEmbeddedMetadata) return@post
val metadataBuilder = currentItemNow.mediaMetadata.buildUpon()
val newExtras = Bundle(currentExtras ?: Bundle())
// Store individual values in extras for UI
artist?.let { newExtras.putString("radioArtist", it) }
title?.let { newExtras.putString("radioTitle", it) }
// Get station name (preserve if already set)
val stationName = currentExtras?.getString("stationName")
?: currentItemNow.mediaMetadata.title?.toString()
?: ""
if (stationName.isNotBlank()) {
newExtras.putString("stationName", stationName)
}
// Format for notification/player: Title = "Artist - Song", Artist = "Station Name"
val formattedTitle = when {
!artist.isNullOrBlank() && !title.isNullOrBlank() -> "$artist - $title"
!title.isNullOrBlank() -> title
!artist.isNullOrBlank() -> artist
else -> stationName
}
metadataBuilder.setTitle(formattedTitle)
if (stationName.isNotBlank()) {
metadataBuilder.setArtist(stationName)
}
metadataBuilder.setExtras(newExtras)
(player as? ExoPlayer)?.let { exo ->
exo.replaceMediaItem(currentIndex, currentItemNow.buildUpon()
.setMediaMetadata(metadataBuilder.build())
.build())
updateWidget(exo)
}
}
}
private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean {
if (audioSessionId == 0 || audioSessionId == -1) return false
val attached = equalizerManager.attachToSession(audioSessionId)
if (attached) {
val enabled = Preferences.isEqualizerEnabled()
equalizerManager.setEnabled(enabled)
val bands = equalizerManager.getNumberOfBands()
val savedLevels = Preferences.getEqualizerBandLevels(bands)
for (i in 0 until bands) {
equalizerManager.setBandLevel(i.toShort(), savedLevels[i])
}
sendBroadcast(Intent(ACTION_EQUALIZER_UPDATED))
}
return attached
}
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
private fun getMediaSourceFactory(): MediaSource.Factory = DynamicMediaSourceFactory(this)
@UnstableApi
private class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback {
private val shuffleCommands: List<CommandButton>
private val repeatCommands: List<CommandButton>
constructor(ctx: Context) {
shuffleCommands = listOf(
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON,
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF
)
.map { getShuffleCommandButton(SessionCommand(it, Bundle.EMPTY), ctx) }
repeatCommands = listOf(
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL
)
.map { getRepeatCommandButton(SessionCommand(it, Bundle.EMPTY), ctx) }
}
override fun onConnect(
session: MediaSession,
controller: ControllerInfo
): MediaSession.ConnectionResult {
val connectionResult = super.onConnect(session, controller)
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
(shuffleCommands + repeatCommands).forEach { commandButton ->
commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
}
val result = MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(availableSessionCommands.build())
.setAvailablePlayerCommands(connectionResult.availablePlayerCommands)
.setMediaButtonPreferences(buildCustomLayout(session.player))
.build()
return result
}
override fun onCustomCommand(
session: MediaSession,
controller: ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
Log.d(TAG, "onCustomCommand")
when (customCommand.customAction) {
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> session.player.shuffleModeEnabled = true
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> session.player.shuffleModeEnabled = false
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> {
val nextMode = when (session.player.repeatMode) {
Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL
Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE
else -> Player.REPEAT_MODE_OFF
}
session.player.repeatMode = nextMode
}
}
session.setMediaButtonPreferences(buildCustomLayout(session.player))
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: ControllerInfo,
mediaItems: List<MediaItem>
): ListenableFuture<List<MediaItem>> {
Log.d(TAG, "onAddMediaItems")
val updatedMediaItems = mediaItems.map { mediaItem ->
val mediaMetadata = mediaItem.mediaMetadata
val newMetadata = mediaMetadata.buildUpon()
.setArtist(
if (mediaMetadata.artist != null) mediaMetadata.artist
else mediaMetadata.extras?.getString("uri") ?: ""
)
.build()
mediaItem.buildUpon()
.setUri(mediaItem.requestMetadata.mediaUri)
.setMediaMetadata(newMetadata)
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)
.build()
}
return Futures.immediateFuture(updatedMediaItems)
}
@SuppressLint("PrivateResource")
private fun getShuffleCommandButton(
sessionCommand: SessionCommand,
ctx: Context
): CommandButton {
val isOn = sessionCommand.customAction == CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON
return CommandButton.Builder(if (isOn) CommandButton.ICON_SHUFFLE_OFF else CommandButton.ICON_SHUFFLE_ON)
.setSessionCommand(sessionCommand)
.setDisplayName(
ctx.getString(
if (isOn) R.string.exo_controls_shuffle_on_description
else R.string.exo_controls_shuffle_off_description
)
)
.build()
}
@SuppressLint("PrivateResource")
private fun getRepeatCommandButton(
sessionCommand: SessionCommand,
ctx: Context
): CommandButton {
val icon = when (sessionCommand.customAction) {
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> CommandButton.ICON_REPEAT_ONE
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> CommandButton.ICON_REPEAT_ALL
else -> CommandButton.ICON_REPEAT_OFF
}
val description = when (sessionCommand.customAction) {
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> R.string.exo_controls_repeat_one_description
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> R.string.exo_controls_repeat_all_description
else -> R.string.exo_controls_repeat_off_description
}
return CommandButton.Builder(icon)
.setSessionCommand(sessionCommand)
.setDisplayName(ctx.getString(description))
.build()
}
private fun buildCustomLayout(player: Player): ImmutableList<CommandButton> {
val shuffle = shuffleCommands[if (player.shuffleModeEnabled) 1 else 0]
val repeat = when (player.repeatMode) {
Player.REPEAT_MODE_ONE -> repeatCommands[1]
Player.REPEAT_MODE_ALL -> repeatCommands[2]
else -> repeatCommands[0]
}
return ImmutableList.of(shuffle, repeat)
}
}
private inner class CustomNetworkCallback : ConnectivityManager.NetworkCallback() {
var wasWifi = false
init {
val manager = getSystemService(ConnectivityManager::class.java)
val network = manager.activeNetwork
val capabilities = manager.getNetworkCapabilities(network)
if (capabilities != null)
wasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
if (isWifi != wasWifi) {
wasWifi = isWifi
widgetUpdateHandler.post {
updateMediaItems(mediaLibrarySession.player)
}
}
}
}
inner class LocalBinder : Binder() {
fun getEqualizerManager(): EqualizerManager {
return equalizerManager
}
}
}
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
private const val RADIO_HEADER_CHECK_INTERVAL_SECONDS = 30L // Reduced frequency - only fallback when ICY fails

View file

@ -1,13 +1,14 @@
package com.cappielloantonio.tempo.service; package com.cappielloantonio.tempo.service;
import android.content.ComponentName; import android.content.ComponentName;
import android.util.Log;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.OptIn; import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer; import androidx.lifecycle.Observer;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
@ -26,7 +27,6 @@ import com.cappielloantonio.tempo.repository.SongRepository;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation; import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode; import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
import com.cappielloantonio.tempo.util.Constants.SeedType;
import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
@ -184,46 +184,40 @@ public class MediaManager {
@OptIn(markerClass = UnstableApi.class) @OptIn(markerClass = UnstableApi.class)
public static void startQueue(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, List<Child> media, int startIndex) { public static void startQueue(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, List<Child> media, int startIndex) {
if (mediaBrowserListenableFuture != null) { if (mediaBrowserListenableFuture != null) {
mediaBrowserListenableFuture.addListener(() -> { mediaBrowserListenableFuture.addListener(() -> {
try { try {
if (mediaBrowserListenableFuture.isDone()) { if (mediaBrowserListenableFuture.isDone()) {
final MediaBrowser browser = mediaBrowserListenableFuture.get(); final MediaBrowser browser = mediaBrowserListenableFuture.get();
final List<MediaItem> items = MappingUtil.mapMediaItems(media);
backgroundExecutor.execute(() -> {
final List<MediaItem> items = MappingUtil.mapMediaItems(media);
enqueueDatabase(media, true, 0);
new Handler(Looper.getMainLooper()).post(() -> { new Handler(Looper.getMainLooper()).post(() -> {
justStarted.set(true); justStarted.set(true);
browser.setMediaItems(items, startIndex, 0); browser.setMediaItems(items, startIndex, 0);
browser.prepare(); browser.prepare();
Player.Listener timelineListener = new Player.Listener() { Player.Listener timelineListener = new Player.Listener() {
@Override @Override
public void onTimelineChanged(Timeline timeline, int reason) { public void onTimelineChanged(Timeline timeline, int reason) {
int itemCount = browser.getMediaItemCount(); int itemCount = browser.getMediaItemCount();
if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) { if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) {
browser.seekTo(startIndex, 0); browser.seekTo(startIndex, 0);
browser.play(); browser.play();
browser.removeListener(this); browser.removeListener(this);
} else {
Log.d(TAG, "Cannot start playback: itemCount=" + itemCount + ", startIndex=" + startIndex);
} }
} }
}; };
browser.addListener(timelineListener); browser.addListener(timelineListener);
}); });
backgroundExecutor.execute(() -> {
Log.d(TAG, "Background: enqueuing to database");
enqueueDatabase(media, true, 0);
}); });
} }
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
Log.e(TAG, "Error in startQueue: " + e.getMessage(), e); Log.e(TAG, "Error executing startQueue logic: " + e.getMessage(), e);
} }
}, MoreExecutors.directExecutor()); }, MoreExecutors.directExecutor());
} }
} }
public static void startQueue(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, Child media) { public static void startQueue(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, Child media) {
@ -444,34 +438,29 @@ public class MediaManager {
} }
@OptIn(markerClass = UnstableApi.class) @OptIn(markerClass = UnstableApi.class)
public static void continuousPlay(MediaItem mediaItem, public static void continuousPlay(MediaItem mediaItem) {
ListenableFuture<MediaBrowser> existingBrowserFuture) { if (mediaItem != null && Preferences.isContinuousPlayEnabled() && Preferences.isInstantMixUsable()) {
if (mediaItem == null
|| !Preferences.isContinuousPlayEnabled()
|| !Preferences.isInstantMixUsable()) {
return;
}
Preferences.setLastInstantMix(); Preferences.setLastInstantMix();
LiveData<List<Child>> instantMix = LiveData<List<Child>> instantMix = getSongRepository().getInstantMix(mediaItem.mediaId, 10);
getSongRepository().getContinuousMix(mediaItem.mediaId, 25);
instantMix.observeForever(new Observer<List<Child>>() { instantMix.observeForever(new Observer<List<Child>>() {
@Override @Override
public void onChanged(List<Child> media) { public void onChanged(List<Child> media) {
if (media == null || media.isEmpty()) { if (media != null) {
return; Log.e(TAG, "continuous play");
ListenableFuture<MediaBrowser> mediaBrowserListenableFuture = new MediaBrowser.Builder(
App.getContext(),
new SessionToken(App.getContext(), new ComponentName(App.getContext(), MediaService.class))
).buildAsync();
enqueue(mediaBrowserListenableFuture, media, true);
} }
if (existingBrowserFuture != null) {
Log.d(TAG, "Continuous play: adding " + media.size() + " tracks");
enqueue(existingBrowserFuture, media, true);
}
instantMix.removeObserver(this); instantMix.removeObserver(this);
} }
}); });
} }
}
public static void saveChronology(MediaItem mediaItem) { public static void saveChronology(MediaItem mediaItem) {
if (mediaItem != null) { if (mediaItem != null) {

View file

@ -3,7 +3,6 @@ package com.cappielloantonio.tempo.subsonic
import com.cappielloantonio.tempo.App import com.cappielloantonio.tempo.App
import com.cappielloantonio.tempo.subsonic.utils.CacheUtil import com.cappielloantonio.tempo.subsonic.utils.CacheUtil
import com.cappielloantonio.tempo.subsonic.utils.EmptyDateTypeAdapter import com.cappielloantonio.tempo.subsonic.utils.EmptyDateTypeAdapter
import com.cappielloantonio.tempo.util.ClientCertManager
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import okhttp3.Cache import okhttp3.Cache
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@ -14,7 +13,7 @@ import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class RetrofitClient(subsonic: Subsonic) { class RetrofitClient(subsonic: Subsonic) {
val retrofit: Retrofit var retrofit: Retrofit
init { init {
val gson = GsonBuilder() val gson = GsonBuilder()
@ -51,7 +50,6 @@ class RetrofitClient(subsonic: Subsonic) {
.addInterceptor(cacheUtil.offlineInterceptor) .addInterceptor(cacheUtil.offlineInterceptor)
// .addNetworkInterceptor(cacheUtil.onlineInterceptor) // .addNetworkInterceptor(cacheUtil.onlineInterceptor)
.cache(getCache()) .cache(getCache())
.setupSsl()
.build() .build()
} }
@ -65,11 +63,4 @@ class RetrofitClient(subsonic: Subsonic) {
val cacheSize = 10 * 1024 * 1024 val cacheSize = 10 * 1024 * 1024
return Cache(App.getContext().cacheDir, cacheSize.toLong()) return Cache(App.getContext().cacheDir, cacheSize.toLong())
} }
private fun OkHttpClient.Builder.setupSsl(): OkHttpClient.Builder {
ClientCertManager.sslSocketFactory?.let { sslSocketFactory ->
sslSocketFactory(sslSocketFactory, ClientCertManager.trustManager)
}
return this
}
} }

View file

@ -24,15 +24,13 @@ public class SystemClient {
public Call<ApiResponse> ping() { public Call<ApiResponse> ping() {
Log.d(TAG, "ping()"); Log.d(TAG, "ping()");
int timeoutSeconds = Preferences.getNetworkPingTimeout();
Call<ApiResponse> pingCall = systemService.ping(subsonic.getParams()); Call<ApiResponse> pingCall = systemService.ping(subsonic.getParams());
if (Preferences.isInUseServerAddressLocal()) { if (Preferences.isInUseServerAddressLocal()) {
pingCall.timeout() pingCall.timeout()
.timeout(timeoutSeconds, TimeUnit.SECONDS); .timeout(1, TimeUnit.SECONDS);
} else { } else {
int finalTimeout = Math.min(timeoutSeconds * 2, 10);
pingCall.timeout() pingCall.timeout()
.timeout(finalTimeout, TimeUnit.SECONDS); .timeout(3, TimeUnit.SECONDS);
} }
return pingCall; return pingCall;
} }

View file

@ -3,7 +3,6 @@ package com.cappielloantonio.tempo.subsonic.models
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.Keep import androidx.annotation.Keep
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import com.google.gson.annotations.SerializedName
@Keep @Keep
@Parcelize @Parcelize
@ -11,6 +10,5 @@ class InternetRadioStation(
var id: String? = null, var id: String? = null,
var name: String? = null, var name: String? = null,
var streamUrl: String? = null, var streamUrl: String? = null,
@SerializedName("homePageUrl", alternate = ["homepageUrl"])
var homePageUrl: String? = null, var homePageUrl: String? = null,
) : Parcelable ) : Parcelable

View file

@ -22,7 +22,6 @@ open class Playlist(
var name: String? = null, var name: String? = null,
@ColumnInfo(name = "duration") @ColumnInfo(name = "duration")
var duration: Long = 0, var duration: Long = 0,
@SerializedName("coverArt")
@ColumnInfo(name = "coverArt") @ColumnInfo(name = "coverArt")
var coverArtId: String? = null, var coverArtId: String? = null,
) : Parcelable { ) : Parcelable {

View file

@ -1,10 +1,8 @@
package com.cappielloantonio.tempo.subsonic.models package com.cappielloantonio.tempo.subsonic.models
import androidx.annotation.Keep import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep @Keep
class SimilarSongs { class SimilarSongs {
@SerializedName("song")
var songs: List<Child>? = null var songs: List<Child>? = null
} }

View file

@ -62,8 +62,7 @@ public class CacheUtil {
boolean hasAppropriateTransport = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) boolean hasAppropriateTransport = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET);
|| capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH);
if (!hasAppropriateTransport) { if (!hasAppropriateTransport) {
return false; return false;
} }

View file

@ -3,31 +3,23 @@ package com.cappielloantonio.tempo.ui.activity;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter; import android.content.IntentFilter;
import android.content.res.Configuration;
import android.graphics.Rect;
import android.content.IntentFilter;
import android.net.ConnectivityManager; import android.net.ConnectivityManager;
import android.net.NetworkInfo; import android.net.NetworkInfo;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.text.TextUtils; import android.text.TextUtils;
import android.view.Gravity; import android.util.Log;
import android.view.View; import android.view.View;
import android.widget.FrameLayout;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.core.splashscreen.SplashScreen; import androidx.core.splashscreen.SplashScreen;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata; import androidx.media3.common.MediaMetadata;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.Player; import androidx.media3.common.Player;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController; import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
@ -53,7 +45,6 @@ import com.cappielloantonio.tempo.viewmodel.MainViewModel;
import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.bottomsheet.BottomSheetBehavior;
import com.google.android.material.color.DynamicColors; import com.google.android.material.color.DynamicColors;
import com.google.android.material.navigation.NavigationView;
import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.MoreExecutors;
import java.util.Objects; import java.util.Objects;
@ -69,12 +60,8 @@ public class MainActivity extends BaseActivity {
private FragmentManager fragmentManager; private FragmentManager fragmentManager;
private NavHostFragment navHostFragment; private NavHostFragment navHostFragment;
private BottomNavigationView bottomNavigationView; private BottomNavigationView bottomNavigationView;
private FrameLayout bottomNavigationViewFrame;
public NavController navController; public NavController navController;
private DrawerLayout drawerLayout;
private NavigationView navigationView;
private BottomSheetBehavior bottomSheetBehavior; private BottomSheetBehavior bottomSheetBehavior;
public boolean isLandscape = false;
private AssetLinkNavigator assetLinkNavigator; private AssetLinkNavigator assetLinkNavigator;
private AssetLinkUtil.AssetLink pendingAssetLink; private AssetLinkUtil.AssetLink pendingAssetLink;
@ -98,8 +85,6 @@ public class MainActivity extends BaseActivity {
connectivityStatusBroadcastReceiver = new ConnectivityStatusBroadcastReceiver(this); connectivityStatusBroadcastReceiver = new ConnectivityStatusBroadcastReceiver(this);
connectivityStatusReceiverManager(true); connectivityStatusReceiverManager(true);
isLandscape = (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE);
init(); init();
checkConnectionType(); checkConnectionType();
getOpenSubsonicExtensions(); getOpenSubsonicExtensions();
@ -120,7 +105,6 @@ public class MainActivity extends BaseActivity {
protected void onResume() { protected void onResume() {
super.onResume(); super.onResume();
pingServer(); pingServer();
toggleNavigationDrawerLockOnOrientationChange();
} }
@Override @Override
@ -157,9 +141,6 @@ public class MainActivity extends BaseActivity {
} else { } else {
goToLogin(); goToLogin();
} }
toggleNavigationDrawerLockOnOrientationChange();
} }
// BOTTOM SHEET/NAVIGATION // BOTTOM SHEET/NAVIGATION
@ -234,10 +215,8 @@ public class MainActivity extends BaseActivity {
@Override @Override
public void onSlide(@NonNull View view, float slideOffset) { public void onSlide(@NonNull View view, float slideOffset) {
animateBottomSheet(slideOffset); animateBottomSheet(slideOffset);
if (!isLandscape) {
animateBottomNavigation(slideOffset, navigationHeight); animateBottomNavigation(slideOffset, navigationHeight);
} }
}
}; };
private void animateBottomSheet(float slideOffset) { private void animateBottomSheet(float slideOffset) {
@ -263,12 +242,8 @@ public class MainActivity extends BaseActivity {
private void initNavigation() { private void initNavigation() {
bottomNavigationView = findViewById(R.id.bottom_navigation); bottomNavigationView = findViewById(R.id.bottom_navigation);
bottomNavigationViewFrame = findViewById(R.id.bottom_navigation_frame);
navHostFragment = (NavHostFragment) fragmentManager.findFragmentById(R.id.nav_host_fragment); navHostFragment = (NavHostFragment) fragmentManager.findFragmentById(R.id.nav_host_fragment);
navController = Objects.requireNonNull(navHostFragment).getNavController(); navController = Objects.requireNonNull(navHostFragment).getNavController();
// This is the lateral slide-in drawer
drawerLayout = findViewById(R.id.drawer_layout);
navigationView = findViewById(R.id.nav_view);
/* /*
* In questo modo intercetto il cambio schermata tramite navbar e se il bottom sheet è aperto, * In questo modo intercetto il cambio schermata tramite navbar e se il bottom sheet è aperto,
@ -285,90 +260,16 @@ public class MainActivity extends BaseActivity {
}); });
NavigationUI.setupWithNavController(bottomNavigationView, navController); NavigationUI.setupWithNavController(bottomNavigationView, navController);
NavigationUI.setupWithNavController(navigationView, navController);
} }
public void setBottomNavigationBarVisibility(boolean visibility) { public void setBottomNavigationBarVisibility(boolean visibility) {
if (visibility) { if (visibility) {
bottomNavigationView.setVisibility(View.VISIBLE); bottomNavigationView.setVisibility(View.VISIBLE);
bottomNavigationViewFrame.setVisibility(View.VISIBLE);
} else { } else {
bottomNavigationView.setVisibility(View.GONE); bottomNavigationView.setVisibility(View.GONE);
bottomNavigationViewFrame.setVisibility(View.GONE);
} }
} }
public void toggleBottomNavigationBarVisibilityOnOrientationChange() {
// Ignore orientation change, bottom navbar always hidden
if (Preferences.getHideBottomNavbarOnPortrait()) {
setBottomNavigationBarVisibility(false);
setPortraitPlayerBottomSheetPeekHeight(56);
setSystemBarsVisibility(!isLandscape);
return;
}
if (!isLandscape) {
// Show app navbar + show system bars
setPortraitPlayerBottomSheetPeekHeight(136);
setBottomNavigationBarVisibility(true);
setSystemBarsVisibility(true);
} else {
// Hide app navbar + hide system bars
setPortraitPlayerBottomSheetPeekHeight(56);
setBottomNavigationBarVisibility(false);
setSystemBarsVisibility(false);
}
}
public void setNavigationDrawerLock(boolean locked) {
int mode = locked
? DrawerLayout.LOCK_MODE_LOCKED_CLOSED
: DrawerLayout.LOCK_MODE_UNLOCKED;
drawerLayout.setDrawerLockMode(mode);
}
public void toggleNavigationDrawerLockOnOrientationChange() {
// Ignore orientation check, drawer always unlocked
if (Preferences.getEnableDrawerOnPortrait()) {
setNavigationDrawerLock(false);
return;
}
if (!isLandscape) {
setNavigationDrawerLock(true);
} else {
setNavigationDrawerLock(false);
}
}
public void setSystemBarsVisibility(boolean visibility) {
WindowInsetsControllerCompat insetsController;
View decorView = getWindow().getDecorView();
insetsController = new WindowInsetsControllerCompat(getWindow(), decorView);
if (visibility) {
WindowCompat.setDecorFitsSystemWindows(getWindow(), true);
insetsController.show(WindowInsetsCompat.Type.navigationBars());
insetsController.show(WindowInsetsCompat.Type.statusBars());
insetsController.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_DEFAULT);
} else {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
insetsController.hide(WindowInsetsCompat.Type.navigationBars());
insetsController.hide(WindowInsetsCompat.Type.statusBars());
insetsController.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
}
private void setPortraitPlayerBottomSheetPeekHeight(int peekHeight) {
FrameLayout bottomSheet = findViewById(R.id.player_bottom_sheet);
BottomSheetBehavior<FrameLayout> behavior =
BottomSheetBehavior.from(bottomSheet);
int newPeekPx = (int) (peekHeight * getResources().getDisplayMetrics().density);
behavior.setPeekHeight(newPeekPx);
}
private void initService() { private void initService() {
MediaManager.check(getMediaBrowserListenableFuture()); MediaManager.check(getMediaBrowserListenableFuture());
@ -450,11 +351,10 @@ public class MainActivity extends BaseActivity {
Preferences.setServer(null); Preferences.setServer(null);
Preferences.setLocalAddress(null); Preferences.setLocalAddress(null);
Preferences.setUser(null); Preferences.setUser(null);
Preferences.setClientCert(null);
// TODO Enter all settings to be reset // TODO Enter all settings to be reset
Preferences.setOpenSubsonic(false); Preferences.setOpenSubsonic(false);
Preferences.setPlaybackSpeed(1.0f); Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_100);
Preferences.setSkipSilenceMode(false); Preferences.setSkipSilenceMode(false);
Preferences.setDataSavingMode(false); Preferences.setDataSavingMode(false);
Preferences.setStarredSyncEnabled(false); Preferences.setStarredSyncEnabled(false);
@ -484,7 +384,7 @@ public class MainActivity extends BaseActivity {
} }
private void pingServer() { private void pingServer() {
if (Preferences.getToken() == null && Preferences.getPassword() == null) return; if (Preferences.getToken() == null) return;
if (Preferences.isInUseServerAddressLocal()) { if (Preferences.isInUseServerAddressLocal()) {
mainViewModel.ping().observe(this, subsonicResponse -> { mainViewModel.ping().observe(this, subsonicResponse -> {
@ -528,7 +428,7 @@ public class MainActivity extends BaseActivity {
} }
private void getOpenSubsonicExtensions() { private void getOpenSubsonicExtensions() {
if (Preferences.getToken() != null || Preferences.getPassword() != null) { if (Preferences.getToken() != null) {
mainViewModel.getOpenSubsonicExtensions().observe(this, openSubsonicExtensions -> { mainViewModel.getOpenSubsonicExtensions().observe(this, openSubsonicExtensions -> {
if (openSubsonicExtensions != null) { if (openSubsonicExtensions != null) {
Preferences.setOpenSubsonicExtensions(openSubsonicExtensions); Preferences.setOpenSubsonicExtensions(openSubsonicExtensions);

View file

@ -146,7 +146,7 @@ public class ArtistCatalogueAdapter extends RecyclerView.Adapter<ArtistCatalogue
public void sort(String order) { public void sort(String order) {
switch (order) { switch (order) {
case Constants.ARTIST_ORDER_BY_NAME: case Constants.ARTIST_ORDER_BY_NAME:
artists.sort(Comparator.comparing(ArtistID3::getName,String.CASE_INSENSITIVE_ORDER)); artists.sort(Comparator.comparing(ArtistID3::getName));
break; break;
case Constants.ARTIST_ORDER_BY_RANDOM: case Constants.ARTIST_ORDER_BY_RANDOM:
Collections.shuffle(artists); Collections.shuffle(artists);

View file

@ -42,13 +42,8 @@ public class InternetRadioStationAdapter extends RecyclerView.Adapter<InternetRa
holder.item.internetRadioStationTitleTextView.setText(internetRadioStation.getName()); holder.item.internetRadioStationTitleTextView.setText(internetRadioStation.getName());
holder.item.internetRadioStationSubtitleTextView.setText(internetRadioStation.getStreamUrl()); holder.item.internetRadioStationSubtitleTextView.setText(internetRadioStation.getStreamUrl());
String imageId = internetRadioStation.getHomePageUrl();
if (imageId == null || imageId.isEmpty()) {
imageId = internetRadioStation.getStreamUrl();
}
CustomGlideRequest.Builder CustomGlideRequest.Builder
.from(holder.itemView.getContext(), imageId, CustomGlideRequest.ResourceType.Radio) .from(holder.itemView.getContext(), internetRadioStation.getStreamUrl(), CustomGlideRequest.ResourceType.Radio)
.build() .build()
.into(holder.item.internetRadioStationCoverImageView); .into(holder.item.internetRadioStationCoverImageView);
} }

View file

@ -18,12 +18,9 @@ import com.cappielloantonio.tempo.databinding.ItemPlayerQueueSongBinding;
import com.cappielloantonio.tempo.glide.CustomGlideRequest; import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.interfaces.ClickCallback; import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.interfaces.MediaIndexCallback; import com.cappielloantonio.tempo.interfaces.MediaIndexCallback;
import com.cappielloantonio.tempo.service.DownloaderManager;
import com.cappielloantonio.tempo.service.MediaManager; import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.Preferences;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
@ -32,9 +29,7 @@ import com.google.common.util.concurrent.MoreExecutors;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueueAdapter.ViewHolder> { public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueueAdapter.ViewHolder> {
private static final String TAG = "PlayerSongQueueAdapter"; private static final String TAG = "PlayerSongQueueAdapter";
@ -42,7 +37,7 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture; private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private List<Child> songs; private List<Child> songs;
private final Map<String, Boolean> downloadStatusCache = new ConcurrentHashMap<>();
private String currentPlayingId; private String currentPlayingId;
private boolean isPlaying; private boolean isPlaying;
private List<Integer> currentPlayingPositions = Collections.emptyList(); private List<Integer> currentPlayingPositions = Collections.emptyList();
@ -83,6 +78,7 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
.build() .build()
.thumbnail(thumbnail) .thumbnail(thumbnail)
.into(holder.item.queueSongCoverImageView); .into(holder.item.queueSongCoverImageView);
MediaManager.getCurrentIndex(mediaBrowserListenableFuture, new MediaIndexCallback() { MediaManager.getCurrentIndex(mediaBrowserListenableFuture, new MediaIndexCallback() {
@Override @Override
public void onRecovery(int index) { public void onRecovery(int index) {
@ -98,23 +94,6 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
} }
}); });
boolean isDownloaded = false;
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager downloaderManager = DownloadUtil.getDownloadTracker(holder.itemView.getContext());
if (downloaderManager != null) {
isDownloaded = downloaderManager.isDownloaded(song.getId());
}
} else {
isDownloaded = ExternalAudioReader.getUri(song) != null;
}
if (isDownloaded) {
holder.item.downloadIndicatorIcon.setVisibility(View.VISIBLE);
} else {
holder.item.downloadIndicatorIcon.setVisibility(View.GONE);
}
if (Preferences.showItemRating()) { if (Preferences.showItemRating()) {
if (song.getStarred() == null && song.getUserRating() == null) { if (song.getStarred() == null && song.getUserRating() == null) {
holder.item.ratingIndicatorImageView.setVisibility(View.GONE); holder.item.ratingIndicatorImageView.setVisibility(View.GONE);

View file

@ -47,7 +47,6 @@ public class PlaylistHorizontalAdapter extends RecyclerView.Adapter<PlaylistHori
FilterResults results = new FilterResults(); FilterResults results = new FilterResults();
results.values = filteredList; results.values = filteredList;
results.count = filteredList.size();
return results; return results;
} }
@ -55,9 +54,7 @@ public class PlaylistHorizontalAdapter extends RecyclerView.Adapter<PlaylistHori
@Override @Override
protected void publishResults(CharSequence constraint, FilterResults results) { protected void publishResults(CharSequence constraint, FilterResults results) {
playlists.clear(); playlists.clear();
if (results.values != null) { if (results.count > 0) playlists.addAll((List) results.values);
playlists.addAll((List<Playlist>) results.values);
}
notifyDataSetChanged(); notifyDataSetChanged();
} }
}; };

View file

@ -173,12 +173,10 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
) )
) )
) { ) {
holder.item.differentDiskDividerSector.setVisibility(View.VISIBLE);
if (songs.get(position).getDiscNumber() != null && !Objects.requireNonNull(songs.get(position).getDiscNumber()).toString().isBlank()) { if (songs.get(position).getDiscNumber() != null && !Objects.requireNonNull(songs.get(position).getDiscNumber()).toString().isBlank()) {
holder.item.discTitleTextView.setText(holder.itemView.getContext().getString(R.string.disc_titleless, songs.get(position).getDiscNumber().toString())); holder.item.discTitleTextView.setText(holder.itemView.getContext().getString(R.string.disc_titleless, songs.get(position).getDiscNumber().toString()));
holder.item.differentDiskDividerSector.setVisibility(View.VISIBLE);
} else {
holder.item.differentDiskDividerSector.setVisibility(View.GONE);
} }
if (album.getDiscTitles() != null) { if (album.getDiscTitles() != null) {
@ -359,7 +357,6 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
private boolean onLongClick() { private boolean onLongClick() {
Bundle bundle = new Bundle(); Bundle bundle = new Bundle();
bundle.putParcelable(Constants.TRACK_OBJECT, songs.get(getBindingAdapterPosition())); bundle.putParcelable(Constants.TRACK_OBJECT, songs.get(getBindingAdapterPosition()));
bundle.putInt(Constants.ITEM_POSITION, getBindingAdapterPosition());
click.onMediaLongClick(bundle); click.onMediaLongClick(bundle);

View file

@ -1,57 +0,0 @@
package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.util.Preferences;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
public class PlaybackSpeedDialog extends DialogFragment {
private static final String TAG = "PlaybackSpeedDialog";
public interface PlaybackSpeedListener {
void onSpeedSelected(float speed);
}
private PlaybackSpeedListener listener;
private static final float[] SPEED_VALUES = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f};
private static final String[] SPEED_LABELS = {"0.5x", "0.75x", "1.0x", "1.25x", "1.5x", "1.75x", "2.0x"};
public void setPlaybackSpeedListener(PlaybackSpeedListener listener) {
this.listener = listener;
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
float currentSpeed = Preferences.getPlaybackSpeed();
int selectedIndex = getSelectedIndex(currentSpeed);
return new MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.playback_speed_dialog_title)
.setSingleChoiceItems(SPEED_LABELS, selectedIndex, (dialog, which) -> {
float selectedSpeed = SPEED_VALUES[which];
Preferences.setPlaybackSpeed(selectedSpeed);
if (listener != null) {
listener.onSpeedSelected(selectedSpeed);
}
dialog.dismiss();
})
.setNegativeButton(R.string.playback_speed_dialog_negative_button, (dialog, id) -> dialog.cancel())
.create();
}
private int getSelectedIndex(float currentSpeed) {
for (int i = 0; i < SPEED_VALUES.length; i++) {
if (Math.abs(SPEED_VALUES[i] - currentSpeed) < 0.01f) {
return i;
}
}
return 2; // Default to 1.0x
}
}

View file

@ -19,30 +19,28 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.viewmodel.PlaylistChooserViewModel; import com.cappielloantonio.tempo.viewmodel.PlaylistChooserViewModel;
import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.util.Objects;
public class PlaylistChooserDialog extends DialogFragment implements ClickCallback { public class PlaylistChooserDialog extends DialogFragment implements ClickCallback {
private DialogPlaylistChooserBinding bind; private DialogPlaylistChooserBinding bind;
private PlaylistChooserViewModel playlistChooserViewModel; private PlaylistChooserViewModel playlistChooserViewModel;
private PlaylistDialogHorizontalAdapter playlistDialogHorizontalAdapter; private PlaylistDialogHorizontalAdapter playlistDialogHorizontalAdapter;
@NonNull @NonNull
@Override @Override
public Dialog onCreateDialog(Bundle savedInstanceState) { public Dialog onCreateDialog(Bundle savedInstanceState) {
DialogPlaylistChooserBinding.inflate(getLayoutInflater());
bind = DialogPlaylistChooserBinding.inflate(getLayoutInflater()); bind = DialogPlaylistChooserBinding.inflate(getLayoutInflater());
playlistChooserViewModel = new ViewModelProvider(requireActivity()).get(PlaylistChooserViewModel.class); playlistChooserViewModel = new ViewModelProvider(requireActivity()).get(PlaylistChooserViewModel.class);
bind.playlistDialogChooserVisibilitySwitch.setOnCheckedChangeListener( return new MaterialAlertDialogBuilder(getActivity())
(buttonView,
isChecked) -> playlistChooserViewModel.setIsPlaylistPublic(isChecked)
);
bind.playlistChooserDialogCreateButton.setOnClickListener(v -> launchPlaylistEditor());
bind.playlistChooserDialogCancelButton.setOnClickListener(v -> dismiss());
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext())
.setView(bind.getRoot()) .setView(bind.getRoot())
.setTitle(R.string.playlist_chooser_dialog_title); .setTitle(R.string.playlist_chooser_dialog_title)
return builder.create(); .setNeutralButton(R.string.playlist_chooser_dialog_neutral_button, (dialog, id) -> { })
.setNegativeButton(R.string.playlist_chooser_dialog_negative_button, (dialog, id) -> dialog.cancel())
.create();
} }
@Override @Override
@ -57,26 +55,25 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba
initPlaylistView(); initPlaylistView();
setSongInfo(); setSongInfo();
setButtonAction();
} }
private void setSongInfo() { private void setSongInfo() {
playlistChooserViewModel.setSongsToAdd(requireArguments().getParcelableArrayList(Constants.TRACKS_OBJECT)); playlistChooserViewModel.setSongsToAdd(requireArguments().getParcelableArrayList(Constants.TRACKS_OBJECT));
} }
private void launchPlaylistEditor() { private void setButtonAction() {
androidx.appcompat.app.AlertDialog alertDialog = (androidx.appcompat.app.AlertDialog) Objects.requireNonNull(getDialog());
alertDialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> {
Bundle bundle = new Bundle(); Bundle bundle = new Bundle();
bundle.putParcelableArrayList( bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, playlistChooserViewModel.getSongsToAdd());
Constants.TRACKS_OBJECT,
playlistChooserViewModel.getSongsToAdd()
);
PlaylistEditorDialog editorDialog = new PlaylistEditorDialog(null); PlaylistEditorDialog dialog = new PlaylistEditorDialog(null);
editorDialog.setArguments(bundle); dialog.setArguments(bundle);
editorDialog.show( dialog.show(requireActivity().getSupportFragmentManager(), null);
requireActivity().getSupportFragmentManager(),
null);
dismiss(); Objects.requireNonNull(getDialog()).dismiss();
});
} }
private void initPlaylistView() { private void initPlaylistView() {

View file

@ -3,13 +3,11 @@ package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog; import android.app.Dialog;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils; import android.text.TextUtils;
import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment; import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.DialogRadioEditorBinding; import com.cappielloantonio.tempo.databinding.DialogRadioEditorBinding;
import com.cappielloantonio.tempo.interfaces.RadioCallback; import com.cappielloantonio.tempo.interfaces.RadioCallback;
@ -23,6 +21,7 @@ import java.util.Objects;
public class RadioEditorDialog extends DialogFragment { public class RadioEditorDialog extends DialogFragment {
private DialogRadioEditorBinding bind; private DialogRadioEditorBinding bind;
private RadioEditorViewModel radioEditorViewModel; private RadioEditorViewModel radioEditorViewModel;
private final RadioCallback radioCallback; private final RadioCallback radioCallback;
private String radioName; private String radioName;
@ -37,9 +36,8 @@ public class RadioEditorDialog extends DialogFragment {
@Override @Override
public Dialog onCreateDialog(Bundle savedInstanceState) { public Dialog onCreateDialog(Bundle savedInstanceState) {
bind = DialogRadioEditorBinding.inflate(getLayoutInflater()); bind = DialogRadioEditorBinding.inflate(getLayoutInflater());
radioEditorViewModel = new ViewModelProvider(requireActivity()).get(RadioEditorViewModel.class);
setupObservers(); radioEditorViewModel = new ViewModelProvider(requireActivity()).get(RadioEditorViewModel.class);
return new MaterialAlertDialogBuilder(requireContext()) return new MaterialAlertDialogBuilder(requireContext())
.setView(bind.getRoot()) .setView(bind.getRoot())
@ -47,16 +45,16 @@ public class RadioEditorDialog extends DialogFragment {
.setPositiveButton(R.string.radio_editor_dialog_positive_button, (dialog, id) -> { .setPositiveButton(R.string.radio_editor_dialog_positive_button, (dialog, id) -> {
if (validateInput()) { if (validateInput()) {
if (radioEditorViewModel.getRadioToEdit() == null) { if (radioEditorViewModel.getRadioToEdit() == null) {
radioEditorViewModel.createRadio(radioName, radioStreamURL, radioEditorViewModel.createRadio(radioName, radioStreamURL, radioHomepageURL.isEmpty() ? null : radioHomepageURL);
radioHomepageURL.isEmpty() ? null : radioHomepageURL);
} else { } else {
radioEditorViewModel.updateRadio(radioName, radioStreamURL, radioEditorViewModel.updateRadio(radioName, radioStreamURL, radioHomepageURL.isEmpty() ? null : radioHomepageURL);
radioHomepageURL.isEmpty() ? null : radioHomepageURL);
} }
dismissDialog();
} }
}) })
.setNeutralButton(R.string.radio_editor_dialog_neutral_button, (dialog, id) -> { .setNeutralButton(R.string.radio_editor_dialog_neutral_button, (dialog, id) -> {
radioEditorViewModel.deleteRadio(); radioEditorViewModel.deleteRadio();
dismissDialog();
}) })
.setNegativeButton(R.string.radio_editor_dialog_negative_button, (dialog, id) -> { .setNegativeButton(R.string.radio_editor_dialog_negative_button, (dialog, id) -> {
dialog.cancel(); dialog.cancel();
@ -64,24 +62,6 @@ public class RadioEditorDialog extends DialogFragment {
.create(); .create();
} }
private void setupObservers() {
radioEditorViewModel.getIsSuccess().observe(this, isSuccess -> {
if (isSuccess != null && isSuccess) {
Toast.makeText(requireContext(),
radioEditorViewModel.getRadioToEdit() == null ?
App.getContext().getString(R.string.radio_editor_dialog_added) : App.getContext().getString(R.string.radio_editor_dialog_updated),
Toast.LENGTH_SHORT).show();
dismissDialog();
}
});
radioEditorViewModel.getErrorMessage().observe(this, error -> {
if (error != null && !error.isEmpty()) {
Toast.makeText(requireContext(), error, Toast.LENGTH_LONG).show();
radioEditorViewModel.clearError();
}
});
}
@Override @Override
public void onStart() { public void onStart() {
super.onStart(); super.onStart();
@ -97,6 +77,7 @@ public class RadioEditorDialog extends DialogFragment {
private void setParameterInfo() { private void setParameterInfo() {
if (getArguments() != null && getArguments().getParcelable(Constants.INTERNET_RADIO_STATION_OBJECT) != null) { if (getArguments() != null && getArguments().getParcelable(Constants.INTERNET_RADIO_STATION_OBJECT) != null) {
InternetRadioStation toEdit = requireArguments().getParcelable(Constants.INTERNET_RADIO_STATION_OBJECT); InternetRadioStation toEdit = requireArguments().getParcelable(Constants.INTERNET_RADIO_STATION_OBJECT);
radioEditorViewModel.setRadioToEdit(toEdit); radioEditorViewModel.setRadioToEdit(toEdit);
bind.internetRadioStationNameTextView.setText(toEdit.getName()); bind.internetRadioStationNameTextView.setText(toEdit.getName());
@ -109,21 +90,22 @@ public class RadioEditorDialog extends DialogFragment {
radioName = Objects.requireNonNull(bind.internetRadioStationNameTextView.getText()).toString().trim(); radioName = Objects.requireNonNull(bind.internetRadioStationNameTextView.getText()).toString().trim();
radioStreamURL = Objects.requireNonNull(bind.internetRadioStationStreamUrlTextView.getText()).toString().trim(); radioStreamURL = Objects.requireNonNull(bind.internetRadioStationStreamUrlTextView.getText()).toString().trim();
radioHomepageURL = Objects.requireNonNull(bind.internetRadioStationHomepageUrlTextView.getText()).toString().trim(); radioHomepageURL = Objects.requireNonNull(bind.internetRadioStationHomepageUrlTextView.getText()).toString().trim();
if (TextUtils.isEmpty(radioName)) { if (TextUtils.isEmpty(radioName)) {
bind.internetRadioStationNameTextView.setError(getString(R.string.error_required)); bind.internetRadioStationNameTextView.setError(getString(R.string.error_required));
return false; return false;
} }
if (TextUtils.isEmpty(radioStreamURL)) { if (TextUtils.isEmpty(radioStreamURL)) {
bind.internetRadioStationStreamUrlTextView.setError(getString(R.string.error_required)); bind.internetRadioStationStreamUrlTextView.setError(getString(R.string.error_required));
return false; return false;
} }
return true; return true;
} }
private void dismissDialog() { private void dismissDialog() {
if (radioCallback != null) {
radioCallback.onDismiss(); radioCallback.onDismiss();
}
Objects.requireNonNull(getDialog()).dismiss(); Objects.requireNonNull(getDialog()).dismiss();
} }
} }

View file

@ -2,8 +2,8 @@ package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog; import android.app.Dialog;
import android.os.Bundle; import android.os.Bundle;
import android.security.KeyChain;
import android.text.TextUtils; import android.text.TextUtils;
import android.view.View;
import android.widget.Toast; import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@ -32,21 +32,11 @@ public class ServerSignupDialog extends DialogFragment {
private String server; private String server;
private String localAddress; private String localAddress;
private boolean lowSecurity = false; private boolean lowSecurity = false;
private String clientCertAlias;
@NonNull @NonNull
@Override @Override
public Dialog onCreateDialog(Bundle savedInstanceState) { public Dialog onCreateDialog(Bundle savedInstanceState) {
bind = DialogServerSignupBinding.inflate(getLayoutInflater()); bind = DialogServerSignupBinding.inflate(getLayoutInflater());
bind.clientCertTextView.setOnClickListener(v -> {
if (TextUtils.isEmpty(bind.clientCertTextView.getText())) {
KeyChain.choosePrivateKeyAlias(requireActivity(), alias -> {
bind.clientCertTextView.setText(alias);
}, null, null, null, null);
} else {
bind.clientCertTextView.setText(null);
}
});
loginViewModel = new ViewModelProvider(requireActivity()).get(LoginViewModel.class); loginViewModel = new ViewModelProvider(requireActivity()).get(LoginViewModel.class);
@ -84,7 +74,6 @@ public class ServerSignupDialog extends DialogFragment {
bind.serverTextView.setText(loginViewModel.getServerToEdit().getAddress()); bind.serverTextView.setText(loginViewModel.getServerToEdit().getAddress());
bind.localAddressTextView.setText(loginViewModel.getServerToEdit().getLocalAddress()); bind.localAddressTextView.setText(loginViewModel.getServerToEdit().getLocalAddress());
bind.lowSecurityCheckbox.setChecked(loginViewModel.getServerToEdit().isLowSecurity()); bind.lowSecurityCheckbox.setChecked(loginViewModel.getServerToEdit().isLowSecurity());
bind.clientCertTextView.setText(loginViewModel.getServerToEdit().getClientCert());
} }
} else { } else {
loginViewModel.setServerToEdit(null); loginViewModel.setServerToEdit(null);
@ -117,7 +106,6 @@ public class ServerSignupDialog extends DialogFragment {
server = bind.serverTextView.getText() != null && !bind.serverTextView.getText().toString().trim().isBlank() ? bind.serverTextView.getText().toString().trim() : null; server = bind.serverTextView.getText() != null && !bind.serverTextView.getText().toString().trim().isBlank() ? bind.serverTextView.getText().toString().trim() : null;
localAddress = bind.localAddressTextView.getText() != null && !bind.localAddressTextView.getText().toString().trim().isBlank() ? bind.localAddressTextView.getText().toString().trim() : null; localAddress = bind.localAddressTextView.getText() != null && !bind.localAddressTextView.getText().toString().trim().isBlank() ? bind.localAddressTextView.getText().toString().trim() : null;
lowSecurity = bind.lowSecurityCheckbox.isChecked(); lowSecurity = bind.lowSecurityCheckbox.isChecked();
clientCertAlias = bind.clientCertTextView.getText() != null && !bind.clientCertTextView.getText().toString().trim().isBlank() ? bind.clientCertTextView.getText().toString().trim() : null;
if (TextUtils.isEmpty(serverName)) { if (TextUtils.isEmpty(serverName)) {
bind.serverNameTextView.setError(getString(R.string.error_required)); bind.serverNameTextView.setError(getString(R.string.error_required));
@ -149,6 +137,6 @@ public class ServerSignupDialog extends DialogFragment {
private void saveServerPreference() { private void saveServerPreference() {
String serverID = loginViewModel.getServerToEdit() != null ? loginViewModel.getServerToEdit().getServerId() : UUID.randomUUID().toString(); String serverID = loginViewModel.getServerToEdit() != null ? loginViewModel.getServerToEdit().getServerId() : UUID.randomUUID().toString();
loginViewModel.addServer(new Server(serverID, this.serverName, this.username, this.password, this.server, this.localAddress, System.currentTimeMillis(), this.lowSecurity, this.clientCertAlias)); loginViewModel.addServer(new Server(serverID, this.serverName, this.username, this.password, this.server, this.localAddress, System.currentTimeMillis(), this.lowSecurity));
} }
} }

View file

@ -1,6 +1,6 @@
package com.cappielloantonio.tempo.ui.dialog; package com.cappielloantonio.tempo.ui.dialog;
import androidx.appcompat.app.AlertDialog; import android.app.AlertDialog;
import android.app.Dialog; import android.app.Dialog;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils; import android.text.TextUtils;

View file

@ -61,47 +61,13 @@ public class TrackInfoDialog extends DialogFragment {
private void setTrackInfo() { private void setTrackInfo() {
genreLink = null; genreLink = null;
yearLink = null; yearLink = null;
String type = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type") : null;
boolean isRadio = Objects.equals(type, Constants.MEDIA_TYPE_RADIO);
if (isRadio) {
// For radio: always read from extras first (radioArtist, radioTitle, stationName)
// MediaMetadata.title/artist are formatted for notification
String stationName = mediaMetadata.extras != null
? mediaMetadata.extras.getString("stationName",
mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "")
: mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "";
String artist = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioArtist", "")
: "";
String title = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioTitle", "")
: "";
// Format: "Artist - Song" or fallback to title or station name
String mainTitle;
if (!android.text.TextUtils.isEmpty(artist) && !android.text.TextUtils.isEmpty(title)) {
mainTitle = artist + " - " + title;
} else if (!android.text.TextUtils.isEmpty(title)) {
mainTitle = title;
} else if (!android.text.TextUtils.isEmpty(artist)) {
mainTitle = artist;
} else {
mainTitle = stationName;
}
bind.trakTitleInfoTextView.setText(mainTitle);
bind.trakArtistInfoTextView.setText(stationName);
} else {
bind.trakTitleInfoTextView.setText(mediaMetadata.title); bind.trakTitleInfoTextView.setText(mediaMetadata.title);
bind.trakArtistInfoTextView.setText( bind.trakArtistInfoTextView.setText(
mediaMetadata.artist != null mediaMetadata.artist != null
? mediaMetadata.artist ? mediaMetadata.artist
: mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO)
? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder))
: ""); : "");
}
if (mediaMetadata.extras != null) { if (mediaMetadata.extras != null) {
songLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_SONG, mediaMetadata.extras.getString("id")); songLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_SONG, mediaMetadata.extras.getString("id"));
@ -125,27 +91,6 @@ public class TrackInfoDialog extends DialogFragment {
String genreValue = mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder)); String genreValue = mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder));
int yearValue = mediaMetadata.extras.getInt("year", 0); int yearValue = mediaMetadata.extras.getInt("year", 0);
// Handle radio-specific metadata
if (isRadio) {
String stationName = mediaMetadata.extras.getString("stationName", getString(R.string.label_placeholder));
String radioArtist = mediaMetadata.extras.getString("radioArtist", "");
String radioTitle = mediaMetadata.extras.getString("radioTitle", "");
// Show station name in station section
bind.stationInfoSector.setVisibility(android.view.View.VISIBLE);
bind.stationValueSector.setText(stationName);
// Use radio metadata for title/artist if available
if (!android.text.TextUtils.isEmpty(radioTitle)) {
titleValue = radioTitle;
}
if (!android.text.TextUtils.isEmpty(radioArtist)) {
artistValue = radioArtist;
}
} else {
bind.stationInfoSector.setVisibility(android.view.View.GONE);
}
if (genreLink == null && genreValue != null && !genreValue.isEmpty() && !getString(R.string.label_placeholder).contentEquals(genreValue)) { if (genreLink == null && genreValue != null && !genreValue.isEmpty() && !getString(R.string.label_placeholder).contentEquals(genreValue)) {
genreLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_GENRE, genreValue); genreLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_GENRE, genreValue);
} }

View file

@ -2,7 +2,6 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.Context; import android.content.Context;
import android.content.res.Configuration;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
@ -50,7 +49,6 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
private AlbumCatalogueViewModel albumCatalogueViewModel; private AlbumCatalogueViewModel albumCatalogueViewModel;
private AlbumCatalogueAdapter albumAdapter; private AlbumCatalogueAdapter albumAdapter;
private int spanCount = 2;
private String currentSortOrder; private String currentSortOrder;
private List<com.cappielloantonio.tempo.subsonic.models.AlbumID3> originalAlbums; private List<com.cappielloantonio.tempo.subsonic.models.AlbumID3> originalAlbums;
@ -92,10 +90,6 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
bind = FragmentAlbumCatalogueBinding.inflate(inflater, container, false); bind = FragmentAlbumCatalogueBinding.inflate(inflater, container, false);
View view = bind.getRoot(); View view = bind.getRoot();
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
spanCount = Preferences.getLandscapeItemsPerRow();
}
initAppBar(); initAppBar();
initAlbumCatalogueView(); initAlbumCatalogueView();
initProgressLoader(); initProgressLoader();
@ -139,8 +133,8 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
private void initAlbumCatalogueView() { private void initAlbumCatalogueView() {
bind.albumCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount)); bind.albumCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2));
bind.albumCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 20, false)); bind.albumCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(2, 20, false));
bind.albumCatalogueRecyclerView.setHasFixedSize(true); bind.albumCatalogueRecyclerView.setHasFixedSize(true);
albumAdapter = new AlbumCatalogueAdapter(this, true); albumAdapter = new AlbumCatalogueAdapter(this, true);

View file

@ -4,7 +4,7 @@ import android.content.ComponentName;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.os.Parcelable;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.Menu; import android.view.Menu;
import android.view.MenuInflater; import android.view.MenuInflater;
@ -12,7 +12,6 @@ import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.Toast; import android.widget.Toast;
import android.widget.ToggleButton;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@ -61,14 +60,12 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
private SongHorizontalAdapter songHorizontalAdapter; private SongHorizontalAdapter songHorizontalAdapter;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture; private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
/** @noinspection deprecation*/
@Override @Override
public void onCreate(@Nullable Bundle savedInstanceState) { public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setHasOptionsMenu(true); setHasOptionsMenu(true);
} }
/** @noinspection deprecation*/
@Override @Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater); super.onCreateOptionsMenu(menu, inflater);
@ -84,7 +81,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
albumPageViewModel = new ViewModelProvider(requireActivity()).get(AlbumPageViewModel.class); albumPageViewModel = new ViewModelProvider(requireActivity()).get(AlbumPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class); playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init(view); init();
initAppBar(); initAppBar();
initAlbumInfoTextButton(); initAlbumInfoTextButton();
initAlbumNotes(); initAlbumNotes();
@ -122,13 +119,12 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind = null; bind = null;
} }
/** @noinspection deprecation*/
@Override @Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) { public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == R.id.action_rate_album) { if (item.getItemId() == R.id.action_rate_album) {
Bundle bundle = new Bundle(); Bundle bundle = new Bundle();
AlbumID3 album = albumPageViewModel.getAlbum().getValue(); AlbumID3 album = albumPageViewModel.getAlbum().getValue();
bundle.putParcelable(Constants.ALBUM_OBJECT, album); bundle.putParcelable(Constants.ALBUM_OBJECT, (Parcelable) album);
RatingDialog dialog = new RatingDialog(); RatingDialog dialog = new RatingDialog();
dialog.setArguments(bundle); dialog.setArguments(bundle);
dialog.show(requireActivity().getSupportFragmentManager(), null); dialog.show(requireActivity().getSupportFragmentManager(), null);
@ -163,21 +159,8 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
return false; return false;
} }
private void init(View view) { private void init() {
AlbumID3 albumArg = requireArguments().getParcelable(Constants.ALBUM_OBJECT); albumPageViewModel.setAlbum(getViewLifecycleOwner(), requireArguments().getParcelable(Constants.ALBUM_OBJECT));
assert albumArg != null;
albumPageViewModel.setAlbum(getViewLifecycleOwner(), albumArg);
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
favoriteToggle.setChecked(albumArg.getStarred() != null);
favoriteToggle.setOnClickListener(v -> {
albumPageViewModel.setFavorite();
});
albumPageViewModel.getAlbum().observe(getViewLifecycleOwner(), album -> {
if (album != null) {
favoriteToggle.setChecked(album.getStarred() != null);
}
});
} }
private void initAppBar() { private void initAppBar() {

View file

@ -2,7 +2,6 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.Context; import android.content.Context;
import android.content.res.Configuration;
import android.os.Bundle; import android.os.Bundle;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.Menu; import android.view.Menu;
@ -51,7 +50,6 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
private ArtistCatalogueViewModel artistCatalogueViewModel; private ArtistCatalogueViewModel artistCatalogueViewModel;
private ArtistCatalogueAdapter artistAdapter; private ArtistCatalogueAdapter artistAdapter;
private int spanCount = 2;
@Override @Override
public void onCreate(@Nullable Bundle savedInstanceState) { public void onCreate(@Nullable Bundle savedInstanceState) {
@ -68,10 +66,6 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
bind = FragmentArtistCatalogueBinding.inflate(inflater, container, false); bind = FragmentArtistCatalogueBinding.inflate(inflater, container, false);
View view = bind.getRoot(); View view = bind.getRoot();
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
spanCount = Preferences.getLandscapeItemsPerRow();
}
initAppBar(); initAppBar();
initArtistCatalogueView(); initArtistCatalogueView();
@ -114,8 +108,8 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
private void initArtistCatalogueView() { private void initArtistCatalogueView() {
bind.artistCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount)); bind.artistCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2));
bind.artistCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 20, false)); bind.artistCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(2, 20, false));
bind.artistCatalogueRecyclerView.setHasFixedSize(true); bind.artistCatalogueRecyclerView.setHasFixedSize(true);
artistAdapter = new ArtistCatalogueAdapter(this); artistAdapter = new ArtistCatalogueAdapter(this);

View file

@ -2,22 +2,15 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName; import android.content.ComponentName;
import android.content.Intent; import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.drawable.Drawable;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.Button;
import android.widget.Toast; import android.widget.Toast;
import android.widget.ToggleButton;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser; import androidx.media3.session.MediaBrowser;
@ -35,21 +28,18 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.service.MediaManager; import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService; import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3; import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter; import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter;
import com.cappielloantonio.tempo.ui.adapter.ArtistCatalogueAdapter; import com.cappielloantonio.tempo.ui.adapter.ArtistCatalogueAdapter;
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter; import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.ArtistPageViewModel; import com.cappielloantonio.tempo.viewmodel.ArtistPageViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects;
@UnstableApi @UnstableApi
public class ArtistPageFragment extends Fragment implements ClickCallback { public class ArtistPageFragment extends Fragment implements ClickCallback {
@ -64,8 +54,6 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture; private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private int spanCount = 2;
@Override @Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
activity = (MainActivity) getActivity(); activity = (MainActivity) getActivity();
@ -75,11 +63,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
artistPageViewModel = new ViewModelProvider(requireActivity()).get(ArtistPageViewModel.class); artistPageViewModel = new ViewModelProvider(requireActivity()).get(ArtistPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class); playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) { init();
spanCount = Preferences.getLandscapeItemsPerRow();
}
init(view);
initAppBar(); initAppBar();
initArtistInfo(); initArtistInfo();
initPlayButtons(); initPlayButtons();
@ -116,7 +100,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
bind = null; bind = null;
} }
private void init(View view) { private void init() {
artistPageViewModel.setArtist(requireArguments().getParcelable(Constants.ARTIST_OBJECT)); artistPageViewModel.setArtist(requireArguments().getParcelable(Constants.ARTIST_OBJECT));
bind.mostStreamedSongTextViewClickable.setOnClickListener(v -> { bind.mostStreamedSongTextViewClickable.setOnClickListener(v -> {
@ -125,14 +109,6 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
bundle.putParcelable(Constants.ARTIST_OBJECT, artistPageViewModel.getArtist()); bundle.putParcelable(Constants.ARTIST_OBJECT, artistPageViewModel.getArtist());
activity.navController.navigate(R.id.action_artistPageFragment_to_songListPageFragment, bundle); activity.navController.navigate(R.id.action_artistPageFragment_to_songListPageFragment, bundle);
}); });
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
favoriteToggle.setChecked(artistPageViewModel.getArtist().getStarred() != null);
favoriteToggle.setOnClickListener(v -> artistPageViewModel.setFavorite(requireContext()));
Button bioToggle = view.findViewById(R.id.button_toggle_bio);
bioToggle.setOnClickListener(v ->
Toast.makeText(getActivity(), R.string.artist_no_artist_info_toast, Toast.LENGTH_SHORT).show());
} }
private void initAppBar() { private void initAppBar() {
@ -150,118 +126,53 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
if (artistInfo == null) { if (artistInfo == null) {
if (bind != null) bind.artistPageBioSector.setVisibility(View.GONE); if (bind != null) bind.artistPageBioSector.setVisibility(View.GONE);
} else { } else {
if (getContext() != null && bind != null) { String normalizedBio = MusicUtil.forceReadableString(artistInfo.getBiography());
ArtistID3 currentArtist = artistPageViewModel.getArtist();
String primaryId = currentArtist.getCoverArtId() != null && !currentArtist.getCoverArtId().trim().isEmpty()
? currentArtist.getCoverArtId()
: currentArtist.getId();
final String fallbackId = (Objects.requireNonNull(primaryId).equals(currentArtist.getCoverArtId()) && if (bind != null)
currentArtist.getId() != null && bind.artistPageBioSector.setVisibility(!normalizedBio.trim().isEmpty() ? View.VISIBLE : View.GONE);
!currentArtist.getId().equals(primaryId)) if (bind != null)
? currentArtist.getId() bind.bioMoreTextViewClickable.setVisibility(artistInfo.getLastFmUrl() != null ? View.VISIBLE : View.GONE);
: null;
CustomGlideRequest.Builder if (getContext() != null && bind != null) CustomGlideRequest.Builder
.from(requireContext(), primaryId, CustomGlideRequest.ResourceType.Artist) .from(requireContext(), artistPageViewModel.getArtist().getId(), CustomGlideRequest.ResourceType.Artist)
.build()
.listener(new com.bumptech.glide.request.RequestListener<Drawable>() {
@Override
public boolean onLoadFailed(@Nullable com.bumptech.glide.load.engine.GlideException e,
Object model,
@NonNull com.bumptech.glide.request.target.Target<Drawable> target,
boolean isFirstResource) {
if (e != null) {
e.getMessage();
if (e.getMessage().contains("400") && fallbackId != null) {
Log.d("ArtistCover", "Primary ID failed (400), trying fallback: " + fallbackId);
CustomGlideRequest.Builder
.from(requireContext(), fallbackId, CustomGlideRequest.ResourceType.Artist)
.build() .build()
.into(bind.artistBackdropImageView); .into(bind.artistBackdropImageView);
return true;
}
}
return false;
}
@Override if (bind != null) bind.bioTextView.setText(normalizedBio);
public boolean onResourceReady(@NonNull Drawable resource,
@NonNull Object model,
com.bumptech.glide.request.target.Target<Drawable> target,
@NonNull com.bumptech.glide.load.DataSource dataSource,
boolean isFirstResource) {
return false;
}
})
.into(bind.artistBackdropImageView);
}
if (bind != null) { if (bind != null) bind.bioMoreTextViewClickable.setOnClickListener(v -> {
String normalizedBio = MusicUtil.forceReadableString(artistInfo.getBiography()).trim();
String lastFmUrl = artistInfo.getLastFmUrl();
if (normalizedBio.isEmpty()) {
bind.bioTextView.setVisibility(View.GONE);
} else {
bind.bioTextView.setText(normalizedBio);
}
if (lastFmUrl == null) {
bind.bioMoreTextViewClickable.setVisibility(View.GONE);
} else {
bind.bioMoreTextViewClickable.setOnClickListener(v -> {
Intent intent = new Intent(Intent.ACTION_VIEW); Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse(artistInfo.getLastFmUrl())); intent.setData(Uri.parse(artistInfo.getLastFmUrl()));
startActivity(intent); startActivity(intent);
}); });
bind.bioMoreTextViewClickable.setVisibility(View.VISIBLE);
}
if (!normalizedBio.isEmpty() || lastFmUrl != null) { if (bind != null) bind.artistPageBioSector.setVisibility(View.VISIBLE);
View view = bind.getRoot();
Button bioToggle = view.findViewById(R.id.button_toggle_bio);
bioToggle.setOnClickListener(v -> {
if (bind != null) {
boolean displayBio = Preferences.getArtistDisplayBiography();
Preferences.setArtistDisplayBiography(!displayBio);
bind.artistPageBioSector.setVisibility(displayBio ? View.GONE : View.VISIBLE);
}
});
boolean displayBio = Preferences.getArtistDisplayBiography();
bind.artistPageBioSector.setVisibility(displayBio ? View.VISIBLE : View.GONE);
}
}
} }
}); });
} }
private void initPlayButtons() { private void initPlayButtons() {
bind.artistPageShuffleButton.setOnClickListener(v -> artistPageViewModel.getArtistShuffleList().observe(getViewLifecycleOwner(), new Observer<List<Child>>() { bind.artistPageShuffleButton.setOnClickListener(v -> {
@Override artistPageViewModel.getArtistShuffleList().observe(getViewLifecycleOwner(), songs -> {
public void onChanged(List<Child> songs) { if (!songs.isEmpty()) {
if (songs != null && !songs.isEmpty()) {
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0); MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
activity.setBottomSheetInPeek(true); activity.setBottomSheetInPeek(true);
artistPageViewModel.getArtistShuffleList().removeObserver(this); } else {
Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_tracks), Toast.LENGTH_SHORT).show();
} }
} });
})); });
bind.artistPageRadioButton.setOnClickListener(v -> artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), new Observer<List<Child>>() { bind.artistPageRadioButton.setOnClickListener(v -> {
@Override artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), songs -> {
public void onChanged(List<Child> songs) {
if (songs != null && !songs.isEmpty()) { if (songs != null && !songs.isEmpty()) {
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0); MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
activity.setBottomSheetInPeek(true); activity.setBottomSheetInPeek(true);
artistPageViewModel.getArtistInstantMix().removeObserver(this); } else {
Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_radio), Toast.LENGTH_SHORT).show();
} }
} });
})); });
} }
private void initTopSongsView() { private void initTopSongsView() {
@ -284,8 +195,8 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
} }
private void initAlbumsView() { private void initAlbumsView() {
bind.albumsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount)); bind.albumsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2));
bind.albumsRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 20, false)); bind.albumsRecyclerView.addItemDecoration(new GridItemDecoration(2, 20, false));
bind.albumsRecyclerView.setHasFixedSize(true); bind.albumsRecyclerView.setHasFixedSize(true);
albumCatalogueAdapter = new AlbumCatalogueAdapter(this, false); albumCatalogueAdapter = new AlbumCatalogueAdapter(this, false);
@ -303,8 +214,8 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
} }
private void initSimilarArtistsView() { private void initSimilarArtistsView() {
bind.similarArtistsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount)); bind.similarArtistsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2));
bind.similarArtistsRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 20, false)); bind.similarArtistsRecyclerView.addItemDecoration(new GridItemDecoration(2, 20, false));
bind.similarArtistsRecyclerView.setHasFixedSize(true); bind.similarArtistsRecyclerView.setHasFixedSize(true);
artistCatalogueAdapter = new ArtistCatalogueAdapter(this); artistCatalogueAdapter = new ArtistCatalogueAdapter(this);

View file

@ -83,7 +83,7 @@ public class DownloadFragment extends Fragment implements ClickCallback {
super.onStart(); super.onStart();
initializeMediaBrowser(); initializeMediaBrowser();
activity.toggleBottomNavigationBarVisibilityOnOrientationChange(); activity.setBottomNavigationBarVisibility(true);
activity.setBottomSheetVisibility(true); activity.setBottomSheetVisibility(true);
} }

View file

@ -19,32 +19,23 @@ import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.cappielloantonio.tempo.R import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.service.EqualizerManager import com.cappielloantonio.tempo.service.EqualizerManager
import com.cappielloantonio.tempo.service.BaseMediaService
import com.cappielloantonio.tempo.service.MediaService import com.cappielloantonio.tempo.service.MediaService
import com.cappielloantonio.tempo.ui.activity.MainActivity
import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.Preferences
class EqualizerFragment : Fragment() { class EqualizerFragment : Fragment() {
private lateinit var activity: MainActivity
private var equalizerManager: EqualizerManager? = null private var equalizerManager: EqualizerManager? = null
private lateinit var eqBandsContainer: LinearLayout private lateinit var eqBandsContainer: LinearLayout
private lateinit var eqSwitch: Switch private lateinit var eqSwitch: Switch
private lateinit var resetButton: Button private lateinit var resetButton: Button
private lateinit var safeSpace: Space private lateinit var safeSpace: Space
private val bandSeekBars = mutableListOf<SeekBar>() private val bandSeekBars = mutableListOf<SeekBar>()
private var receiverRegistered = false private var receiverRegistered = false
@OptIn(UnstableApi::class)
override fun onAttach(context: Context) {
super.onAttach(context)
activity = requireActivity() as MainActivity
}
private val equalizerUpdatedReceiver = object : BroadcastReceiver() { private val equalizerUpdatedReceiver = object : BroadcastReceiver() {
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == BaseMediaService.ACTION_EQUALIZER_UPDATED) { if (intent?.action == MediaService.ACTION_EQUALIZER_UPDATED) {
initUI() initUI()
restoreEqualizerPreferences() restoreEqualizerPreferences()
} }
@ -54,7 +45,7 @@ class EqualizerFragment : Fragment() {
private val connection = object : ServiceConnection { private val connection = object : ServiceConnection {
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
override fun onServiceConnected(className: ComponentName, service: IBinder) { override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as BaseMediaService.LocalBinder val binder = service as MediaService.LocalBinder
equalizerManager = binder.getEqualizerManager() equalizerManager = binder.getEqualizerManager()
initUI() initUI()
restoreEqualizerPreferences() restoreEqualizerPreferences()
@ -69,20 +60,18 @@ class EqualizerFragment : Fragment() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
Intent(requireContext(), MediaService::class.java).also { intent -> Intent(requireContext(), MediaService::class.java).also { intent ->
intent.action = BaseMediaService.ACTION_BIND_EQUALIZER intent.action = MediaService.ACTION_BIND_EQUALIZER
requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE) requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE)
} }
if (!receiverRegistered) { if (!receiverRegistered) {
ContextCompat.registerReceiver( ContextCompat.registerReceiver(
requireContext(), requireContext(),
equalizerUpdatedReceiver, equalizerUpdatedReceiver,
IntentFilter(BaseMediaService.ACTION_EQUALIZER_UPDATED), IntentFilter(MediaService.ACTION_EQUALIZER_UPDATED),
ContextCompat.RECEIVER_NOT_EXPORTED ContextCompat.RECEIVER_NOT_EXPORTED
) )
receiverRegistered = true receiverRegistered = true
} }
val showBottomBar = !Preferences.getHideBottomNavbarOnPortrait()
activity.setBottomNavigationBarVisibility(showBottomBar)
} }
override fun onStop() { override fun onStop() {

View file

@ -2,7 +2,6 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.Context; import android.content.Context;
import android.content.res.Configuration;
import android.os.Bundle; import android.os.Bundle;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.Menu; import android.view.Menu;
@ -33,7 +32,6 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.GenreCatalogueAdapter; import com.cappielloantonio.tempo.ui.adapter.GenreCatalogueAdapter;
import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.GenreCatalogueViewModel; import com.cappielloantonio.tempo.viewmodel.GenreCatalogueViewModel;
@OptIn(markerClass = UnstableApi.class) @OptIn(markerClass = UnstableApi.class)
@ -43,7 +41,6 @@ public class GenreCatalogueFragment extends Fragment implements ClickCallback {
private GenreCatalogueViewModel genreCatalogueViewModel; private GenreCatalogueViewModel genreCatalogueViewModel;
private GenreCatalogueAdapter genreCatalogueAdapter; private GenreCatalogueAdapter genreCatalogueAdapter;
private int spanCount = 2;
@Override @Override
public void onCreate(@Nullable Bundle savedInstanceState) { public void onCreate(@Nullable Bundle savedInstanceState) {
@ -59,10 +56,6 @@ public class GenreCatalogueFragment extends Fragment implements ClickCallback {
View view = bind.getRoot(); View view = bind.getRoot();
genreCatalogueViewModel = new ViewModelProvider(requireActivity()).get(GenreCatalogueViewModel.class); genreCatalogueViewModel = new ViewModelProvider(requireActivity()).get(GenreCatalogueViewModel.class);
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
spanCount = Preferences.getLandscapeItemsPerRow();
}
init(); init();
initAppBar(); initAppBar();
initGenreCatalogueView(); initGenreCatalogueView();
@ -104,8 +97,8 @@ public class GenreCatalogueFragment extends Fragment implements ClickCallback {
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
private void initGenreCatalogueView() { private void initGenreCatalogueView() {
bind.genreCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount)); bind.genreCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2));
bind.genreCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 16, false)); bind.genreCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(2, 16, false));
bind.genreCatalogueRecyclerView.setHasFixedSize(true); bind.genreCatalogueRecyclerView.setHasFixedSize(true);
genreCatalogueAdapter = new GenreCatalogueAdapter(this); genreCatalogueAdapter = new GenreCatalogueAdapter(this);

View file

@ -53,7 +53,7 @@ public class HomeFragment extends Fragment {
public void onStart() { public void onStart() {
super.onStart(); super.onStart();
activity.toggleBottomNavigationBarVisibilityOnOrientationChange(); activity.setBottomNavigationBarVisibility(true);
activity.setBottomSheetVisibility(true); activity.setBottomSheetVisibility(true);
} }

View file

@ -5,8 +5,6 @@ import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -40,10 +38,10 @@ import com.cappielloantonio.tempo.model.HomeSector;
import com.cappielloantonio.tempo.service.DownloaderManager; import com.cappielloantonio.tempo.service.DownloaderManager;
import com.cappielloantonio.tempo.service.MediaManager; import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService; import com.cappielloantonio.tempo.service.MediaService;
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.Child;
import com.cappielloantonio.tempo.subsonic.models.Share; 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.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter; import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter;
import com.cappielloantonio.tempo.ui.adapter.AlbumHorizontalAdapter; import com.cappielloantonio.tempo.ui.adapter.AlbumHorizontalAdapter;
@ -59,8 +57,6 @@ import com.cappielloantonio.tempo.ui.dialog.HomeRearrangementDialog;
import com.cappielloantonio.tempo.ui.dialog.PlaylistEditorDialog; import com.cappielloantonio.tempo.ui.dialog.PlaylistEditorDialog;
import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.Preferences;
@ -70,6 +66,8 @@ import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.android.material.snackbar.Snackbar; import com.google.android.material.snackbar.Snackbar;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import androidx.media3.common.MediaItem;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -230,12 +228,6 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
activity.navController.navigate(R.id.action_homeFragment_to_albumListPageFragment, bundle); activity.navController.navigate(R.id.action_homeFragment_to_albumListPageFragment, bundle);
}); });
bind.playlistCatalogueTextViewClickable.setOnClickListener(v -> {
Bundle bundle = new Bundle();
bundle.putString(Constants.PLAYLIST_ALL, Constants.PLAYLIST_ALL);
activity.navController.navigate(R.id.action_homeFragment_to_playlistCatalogueFragment, bundle);
});
bind.recentlyPlayedAlbumsTextViewClickable.setOnClickListener(v -> { bind.recentlyPlayedAlbumsTextViewClickable.setOnClickListener(v -> {
Bundle bundle = new Bundle(); Bundle bundle = new Bundle();
bundle.putString(Constants.ALBUM_RECENTLY_PLAYED, Constants.ALBUM_RECENTLY_PLAYED); bundle.putString(Constants.ALBUM_RECENTLY_PLAYED, Constants.ALBUM_RECENTLY_PLAYED);
@ -287,113 +279,51 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
} }
private void initSyncStarredView() { private void initSyncStarredView() {
if (Preferences.isStarredSyncEnabled()) { if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
homeViewModel.getAllStarredTracks().observe(getViewLifecycleOwner(), new Observer<List<Child>>() { homeViewModel.getAllStarredTracks().observeForever(new Observer<List<Child>>() {
@Override @Override
public void onChanged(List<Child> songs) { public void onChanged(List<Child> songs) {
if (songs != null && !songs.isEmpty()) { if (songs != null) {
int songsToSyncCount = 0;
List<String> toSyncSample = new ArrayList<>();
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext()); DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
List<String> toSync = new ArrayList<>();
for (Child song : songs) { for (Child song : songs) {
if (!manager.isDownloaded(song.getId())) { if (!manager.isDownloaded(song.getId())) {
songsToSyncCount++; toSync.add(song.getTitle());
if (toSyncSample.size() < 3) {
toSyncSample.add(song.getTitle());
}
}
}
} else {
for (Child song : songs) {
if (ExternalAudioReader.getUri(song) == null) {
songsToSyncCount++;
if (toSyncSample.size() < 3) {
toSyncSample.add(song.getTitle());
}
}
} }
} }
if (songsToSyncCount > 0) { if (!toSync.isEmpty()) {
bind.homeSyncStarredCard.setVisibility(View.VISIBLE); bind.homeSyncStarredCard.setVisibility(View.VISIBLE);
bind.homeSyncStarredTracksToSync.setText(String.join(", ", toSync));
StringBuilder displayText = new StringBuilder();
if (!toSyncSample.isEmpty()) {
displayText.append(String.join(", ", toSyncSample));
if (songsToSyncCount > 3) {
displayText.append("...");
} }
} }
String countText = getResources().getQuantityString( homeViewModel.getAllStarredTracks().removeObserver(this);
R.plurals.home_sync_starred_songs_count,
songsToSyncCount,
songsToSyncCount
);
if (displayText.length() > 0) {
bind.homeSyncStarredTracksToSync.setText(displayText.toString() + "\n" + countText);
} else {
bind.homeSyncStarredTracksToSync.setText(countText);
}
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
} else {
bind.homeSyncStarredCard.setVisibility(View.GONE);
}
}
} }
}); });
} }
bind.homeSyncStarredCancel.setOnClickListener(v -> { bind.homeSyncStarredCancel.setOnClickListener(v -> bind.homeSyncStarredCard.setVisibility(View.GONE));
bind.homeSyncStarredCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
});
bind.homeSyncStarredDownload.setOnClickListener(new View.OnClickListener() { bind.homeSyncStarredDownload.setOnClickListener(new View.OnClickListener() {
@Override @Override
public void onClick(View v) { public void onClick(View v) {
homeViewModel.getAllStarredTracks().observe(getViewLifecycleOwner(), new Observer<List<Child>>() { homeViewModel.getAllStarredTracks().observeForever(new Observer<List<Child>>() {
@Override @Override
public void onChanged(List<Child> songs) { public void onChanged(List<Child> songs) {
if (songs != null && !songs.isEmpty()) { if (songs != null) {
int downloadedCount = 0;
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext()); DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
for (Child song : songs) { for (Child song : songs) {
if (!manager.isDownloaded(song.getId())) { if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song)); manager.download(MappingUtil.mapDownload(song), new Download(song));
downloadedCount++;
}
}
} else {
for (Child song : songs) {
if (ExternalAudioReader.getUri(song) == null) {
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
downloadedCount++;
} }
} }
} }
if (downloadedCount > 0) { homeViewModel.getAllStarredTracks().removeObserver(this);
Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, downloadedCount, downloadedCount),
Toast.LENGTH_SHORT).show();
}
}
bind.homeSyncStarredCard.setVisibility(View.GONE); bind.homeSyncStarredCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
} }
}); });
} }
@ -401,7 +331,6 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
} }
private void initSyncStarredAlbumsView() { private void initSyncStarredAlbumsView() {
if (Preferences.isStarredAlbumsSyncEnabled()) { if (Preferences.isStarredAlbumsSyncEnabled()) {
homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer<List<AlbumID3>>() { homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer<List<AlbumID3>>() {
@Override @Override
@ -415,9 +344,6 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.homeSyncStarredAlbumsCancel.setOnClickListener(v -> { bind.homeSyncStarredAlbumsCancel.setOnClickListener(v -> {
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE); bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
}); });
bind.homeSyncStarredAlbumsDownload.setOnClickListener(v -> { bind.homeSyncStarredAlbumsDownload.setOnClickListener(v -> {
@ -425,24 +351,15 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
@Override @Override
public void onChanged(List<Child> allSongs) { public void onChanged(List<Child> allSongs) {
if (allSongs != null && !allSongs.isEmpty()) { if (allSongs != null && !allSongs.isEmpty()) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
int songsToDownload = 0; int songsToDownload = 0;
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
for (Child song : allSongs) { for (Child song : allSongs) {
if (!manager.isDownloaded(song.getId())) { if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song)); manager.download(MappingUtil.mapDownload(song), new Download(song));
songsToDownload++; songsToDownload++;
} }
} }
} else {
for (Child song : allSongs) {
if (ExternalAudioReader.getUri(song) == null) {
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
songsToDownload++;
}
}
}
if (songsToDownload > 0) { if (songsToDownload > 0) {
Toast.makeText(requireContext(), Toast.makeText(requireContext(),
@ -452,9 +369,6 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
} }
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE); bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
} }
}); });
}); });
@ -465,14 +379,13 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
@Override @Override
public void onChanged(List<Child> allSongs) { public void onChanged(List<Child> allSongs) {
if (allSongs != null) { if (allSongs != null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
int songsToDownload = 0; int songsToDownload = 0;
List<String> albumsNeedingSync = new ArrayList<>(); List<String> albumsNeedingSync = new ArrayList<>();
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
for (AlbumID3 album : albums) { for (AlbumID3 album : albums) {
boolean albumNeedsSync = false; boolean albumNeedsSync = false;
// Check if any songs from this album need downloading
for (Child song : allSongs) { for (Child song : allSongs) {
if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) && if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) &&
!manager.isDownloaded(song.getId())) { !manager.isDownloaded(song.getId())) {
@ -484,54 +397,15 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
albumsNeedingSync.add(album.getName()); albumsNeedingSync.add(album.getName());
} }
} }
} else {
for (AlbumID3 album : albums) {
boolean albumNeedsSync = false;
for (Child song : allSongs) {
if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) &&
ExternalAudioReader.getUri(song) == null) {
songsToDownload++;
albumNeedsSync = true;
}
}
if (albumNeedsSync) {
albumsNeedingSync.add(album.getName());
}
}
}
if (songsToDownload > 0) { if (songsToDownload > 0) {
bind.homeSyncStarredAlbumsCard.setVisibility(View.VISIBLE); bind.homeSyncStarredAlbumsCard.setVisibility(View.VISIBLE);
String message = getResources().getQuantityString(
StringBuilder displayText = new StringBuilder();
List<String> sampleAlbums = new ArrayList<>();
for (int i = 0; i < Math.min(albumsNeedingSync.size(), 3); i++) {
sampleAlbums.add(albumsNeedingSync.get(i));
}
if (!sampleAlbums.isEmpty()) {
displayText.append(String.join(", ", sampleAlbums));
if (albumsNeedingSync.size() > 3) {
displayText.append("...");
}
}
String countText = getResources().getQuantityString(
R.plurals.home_sync_starred_albums_count, R.plurals.home_sync_starred_albums_count,
albumsNeedingSync.size(), albumsNeedingSync.size(),
albumsNeedingSync.size() albumsNeedingSync.size()
); );
bind.homeSyncStarredAlbumsToSync.setText(message);
if (displayText.length() > 0) {
bind.homeSyncStarredAlbumsToSync.setText(displayText.toString() + "\n" + countText);
} else {
bind.homeSyncStarredAlbumsToSync.setText(countText);
}
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
} else { } else {
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE); bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
} }
@ -554,9 +428,6 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.homeSyncStarredArtistsCancel.setOnClickListener(v -> { bind.homeSyncStarredArtistsCancel.setOnClickListener(v -> {
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE); bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
}); });
bind.homeSyncStarredArtistsDownload.setOnClickListener(v -> { bind.homeSyncStarredArtistsDownload.setOnClickListener(v -> {
@ -564,24 +435,15 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
@Override @Override
public void onChanged(List<Child> allSongs) { public void onChanged(List<Child> allSongs) {
if (allSongs != null && !allSongs.isEmpty()) { if (allSongs != null && !allSongs.isEmpty()) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
int songsToDownload = 0; int songsToDownload = 0;
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
for (Child song : allSongs) { for (Child song : allSongs) {
if (!manager.isDownloaded(song.getId())) { if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song)); manager.download(MappingUtil.mapDownload(song), new Download(song));
songsToDownload++; songsToDownload++;
} }
} }
} else {
for (Child song : allSongs) {
if (ExternalAudioReader.getUri(song) == null) {
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
songsToDownload++;
}
}
}
if (songsToDownload > 0) { if (songsToDownload > 0) {
Toast.makeText(requireContext(), Toast.makeText(requireContext(),
@ -591,9 +453,6 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
} }
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE); bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
} }
}); });
}); });
@ -604,14 +463,13 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
@Override @Override
public void onChanged(List<Child> allSongs) { public void onChanged(List<Child> allSongs) {
if (allSongs != null) { if (allSongs != null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
int songsToDownload = 0; int songsToDownload = 0;
List<String> artistsNeedingSync = new ArrayList<>(); List<String> artistsNeedingSync = new ArrayList<>();
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
for (ArtistID3 artist : artists) { for (ArtistID3 artist : artists) {
boolean artistNeedsSync = false; boolean artistNeedsSync = false;
// Check if any songs from this artist need downloading
for (Child song : allSongs) { for (Child song : allSongs) {
if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) && if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) &&
!manager.isDownloaded(song.getId())) { !manager.isDownloaded(song.getId())) {
@ -623,54 +481,15 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
artistsNeedingSync.add(artist.getName()); artistsNeedingSync.add(artist.getName());
} }
} }
} else {
for (ArtistID3 artist : artists) {
boolean artistNeedsSync = false;
for (Child song : allSongs) {
if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) &&
ExternalAudioReader.getUri(song) == null) {
songsToDownload++;
artistNeedsSync = true;
}
}
if (artistNeedsSync) {
artistsNeedingSync.add(artist.getName());
}
}
}
if (songsToDownload > 0) { if (songsToDownload > 0) {
bind.homeSyncStarredArtistsCard.setVisibility(View.VISIBLE); bind.homeSyncStarredArtistsCard.setVisibility(View.VISIBLE);
String message = getResources().getQuantityString(
StringBuilder displayText = new StringBuilder();
List<String> sampleArtists = new ArrayList<>();
for (int i = 0; i < Math.min(artistsNeedingSync.size(), 3); i++) {
sampleArtists.add(artistsNeedingSync.get(i));
}
if (!sampleArtists.isEmpty()) {
displayText.append(String.join(", ", sampleArtists));
if (artistsNeedingSync.size() > 3) {
displayText.append("...");
}
}
String countText = getResources().getQuantityString(
R.plurals.home_sync_starred_artists_count, R.plurals.home_sync_starred_artists_count,
artistsNeedingSync.size(), artistsNeedingSync.size(),
artistsNeedingSync.size() artistsNeedingSync.size()
); );
bind.homeSyncStarredArtistsToSync.setText(message);
if (displayText.length() > 0) {
bind.homeSyncStarredArtistsToSync.setText(displayText.toString() + "\n" + countText);
} else {
bind.homeSyncStarredArtistsToSync.setText(countText);
}
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
} else { } else {
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE); bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
} }
@ -1143,18 +962,6 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
if (bind != null && homeViewModel.getHomeSectorList() != null) { if (bind != null && homeViewModel.getHomeSectorList() != null) {
bind.homeLinearLayoutContainer.removeAllViews(); bind.homeLinearLayoutContainer.removeAllViews();
if (bind.homeSyncStarredCard.getVisibility() == View.VISIBLE) {
bind.homeLinearLayoutContainer.addView(bind.homeSyncStarredCard);
}
if (bind.homeSyncStarredAlbumsCard.getVisibility() == View.VISIBLE) {
bind.homeLinearLayoutContainer.addView(bind.homeSyncStarredAlbumsCard);
}
if (bind.homeSyncStarredArtistsCard.getVisibility() == View.VISIBLE) {
bind.homeLinearLayoutContainer.addView(bind.homeSyncStarredArtistsCard);
}
for (HomeSector sector : homeViewModel.getHomeSectorList()) { for (HomeSector sector : homeViewModel.getHomeSectorList()) {
if (!sector.isVisible()) continue; if (!sector.isVisible()) continue;
@ -1255,24 +1062,19 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
MediaBrowser.releaseFuture(mediaBrowserListenableFuture); MediaBrowser.releaseFuture(mediaBrowserListenableFuture);
} }
@Override
public void onMediaClick(Bundle bundle) { public void onMediaClick(Bundle bundle) {
if (bundle.containsKey(Constants.MEDIA_MIX)) { if (bundle.containsKey(Constants.MEDIA_MIX)) {
Child track = bundle.getParcelable(Constants.TRACK_OBJECT); MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelable(Constants.TRACK_OBJECT));
activity.setBottomSheetInPeek(true); activity.setBottomSheetInPeek(true);
if (mediaBrowserListenableFuture != null) { if (mediaBrowserListenableFuture != null) {
final boolean[] playbackStarted = {false}; homeViewModel.getMediaInstantMix(getViewLifecycleOwner(), bundle.getParcelable(Constants.TRACK_OBJECT)).observe(getViewLifecycleOwner(), songs -> {
Toast.makeText(requireContext(), R.string.bottom_sheet_generating_instant_mix, Toast.LENGTH_SHORT).show(); MusicUtil.ratingFilter(songs);
homeViewModel.getMediaInstantMix(getViewLifecycleOwner(), track)
.observe(getViewLifecycleOwner(), songs -> {
if (playbackStarted[0] || songs == null || songs.isEmpty()) return;
new Handler(Looper.getMainLooper()).postDelayed(() -> { if (songs != null && !songs.isEmpty()) {
if (playbackStarted[0]) return; MediaManager.enqueue(mediaBrowserListenableFuture, songs, true);
}
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
playbackStarted[0] = true;
}, 300);
}); });
} }
} else if (bundle.containsKey(Constants.MEDIA_CHRONOLOGY)) { } else if (bundle.containsKey(Constants.MEDIA_CHRONOLOGY)) {

View file

@ -9,8 +9,6 @@ import android.view.ViewGroup;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser; import androidx.media3.session.MediaBrowser;
@ -18,11 +16,8 @@ import androidx.media3.session.SessionToken;
import androidx.navigation.Navigation; import androidx.navigation.Navigation;
import android.content.ComponentName; import android.content.ComponentName;
import android.widget.Toast;
import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.FragmentLibraryBinding; import com.cappielloantonio.tempo.databinding.FragmentLibraryBinding;
@ -48,7 +43,6 @@ import java.util.Objects;
@UnstableApi @UnstableApi
public class LibraryFragment extends Fragment implements ClickCallback { public class LibraryFragment extends Fragment implements ClickCallback {
private static final String TAG = "LibraryFragment"; private static final String TAG = "LibraryFragment";
private static final String TOAST_MSG = "Long press to refresh" ;
private FragmentLibraryBinding bind; private FragmentLibraryBinding bind;
private MainActivity activity; private MainActivity activity;
@ -87,14 +81,13 @@ public class LibraryFragment extends Fragment implements ClickCallback {
initArtistView(); initArtistView();
initGenreView(); initGenreView();
initPlaylistView(); initPlaylistView();
initSwipeToRefresh();
} }
@Override @Override
public void onStart() { public void onStart() {
super.onStart(); super.onStart();
initializeMediaBrowser(); initializeMediaBrowser();
activity.toggleBottomNavigationBarVisibilityOnOrientationChange(); activity.setBottomNavigationBarVisibility(true);
} }
@Override @Override
@ -119,41 +112,22 @@ public class LibraryFragment extends Fragment implements ClickCallback {
activity.navController.navigate(R.id.action_libraryFragment_to_playlistCatalogueFragment, bundle); activity.navController.navigate(R.id.action_libraryFragment_to_playlistCatalogueFragment, bundle);
}); });
// Album
bind.albumCatalogueSampleTextViewRefreshable.setOnLongClickListener(view -> { bind.albumCatalogueSampleTextViewRefreshable.setOnLongClickListener(view -> {
libraryViewModel.refreshAlbumSample(getViewLifecycleOwner()); libraryViewModel.refreshAlbumSample(getViewLifecycleOwner());
return true; return true;
}); });
bind.albumCatalogueSampleTextViewRefreshable.setOnClickListener( v ->
Toast.makeText(requireContext(), TOAST_MSG, Toast.LENGTH_SHORT).show()
);
// Artist
bind.artistCatalogueSampleTextViewRefreshable.setOnLongClickListener(view -> { bind.artistCatalogueSampleTextViewRefreshable.setOnLongClickListener(view -> {
libraryViewModel.refreshArtistSample(getViewLifecycleOwner()); libraryViewModel.refreshArtistSample(getViewLifecycleOwner());
return true; return true;
}); });
bind.artistCatalogueSampleTextViewRefreshable.setOnClickListener( v ->
Toast.makeText(requireContext(), TOAST_MSG, Toast.LENGTH_SHORT).show()
);
// Genre
bind.genreCatalogueSampleTextViewRefreshable.setOnLongClickListener(view -> { bind.genreCatalogueSampleTextViewRefreshable.setOnLongClickListener(view -> {
libraryViewModel.refreshGenreSample(getViewLifecycleOwner()); libraryViewModel.refreshGenreSample(getViewLifecycleOwner());
return true; return true;
}); });
bind.genreCatalogueSampleTextViewRefreshable.setOnClickListener(v ->
Toast.makeText(requireContext(), TOAST_MSG, Toast.LENGTH_SHORT).show()
);
// Playlist
bind.playlistCatalogueSampleTextViewRefreshable.setOnLongClickListener(view -> { bind.playlistCatalogueSampleTextViewRefreshable.setOnLongClickListener(view -> {
libraryViewModel.refreshPlaylistSample(getViewLifecycleOwner()); libraryViewModel.refreshPlaylistSample(getViewLifecycleOwner());
return true; return true;
}); });
bind.playlistCatalogueSampleTextViewRefreshable.setOnClickListener( v ->
Toast.makeText(requireContext(), TOAST_MSG, Toast.LENGTH_SHORT).show()
);
} }
private void initAppBar() { private void initAppBar() {
@ -330,20 +304,4 @@ public class LibraryFragment extends Fragment implements ClickCallback {
private void initializeMediaBrowser() { private void initializeMediaBrowser() {
mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync(); mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync();
} }
public void initSwipeToRefresh() {
bind.swipeLibraryToRefresh.setOnRefreshListener(() -> {
pullToRefresh();
bind.swipeLibraryToRefresh.setRefreshing(false);
});
}
private void pullToRefresh() {
LifecycleOwner lifecycleOwner = getViewLifecycleOwner();
libraryViewModel.refreshAlbumSample(lifecycleOwner);
libraryViewModel.refreshGenreSample(lifecycleOwner);
libraryViewModel.refreshArtistSample(lifecycleOwner);
libraryViewModel.refreshPlaylistSample(lifecycleOwner);
}
} }

View file

@ -117,7 +117,7 @@ public class LoginFragment extends Fragment implements ClickCallback {
@Override @Override
public void onServerClick(Bundle bundle) { public void onServerClick(Bundle bundle) {
Server server = bundle.getParcelable("server_object"); Server server = bundle.getParcelable("server_object");
saveServerPreference(server.getServerId(), server.getAddress(), server.getLocalAddress(), server.getUsername(), server.getPassword(), server.isLowSecurity(), server.getClientCert()); saveServerPreference(server.getServerId(), server.getAddress(), server.getLocalAddress(), server.getUsername(), server.getPassword(), server.isLowSecurity());
SystemRepository systemRepository = new SystemRepository(); SystemRepository systemRepository = new SystemRepository();
systemRepository.checkUserCredential(new SystemCallback() { systemRepository.checkUserCredential(new SystemCallback() {
@ -142,14 +142,13 @@ public class LoginFragment extends Fragment implements ClickCallback {
dialog.show(activity.getSupportFragmentManager(), null); dialog.show(activity.getSupportFragmentManager(), null);
} }
private void saveServerPreference(String serverId, String server, String localAddress, String user, String password, boolean isLowSecurity, String clientCert) { private void saveServerPreference(String serverId, String server, String localAddress, String user, String password, boolean isLowSecurity) {
Preferences.setServerId(serverId); Preferences.setServerId(serverId);
Preferences.setServer(server); Preferences.setServer(server);
Preferences.setLocalAddress(localAddress); Preferences.setLocalAddress(localAddress);
Preferences.setUser(user); Preferences.setUser(user);
Preferences.setPassword(password); Preferences.setPassword(password);
Preferences.setLowSecurity(isLowSecurity); Preferences.setLowSecurity(isLowSecurity);
Preferences.setClientCert(clientCert);
App.getSubsonicClientInstance(true); App.getSubsonicClientInstance(true);
} }
@ -162,7 +161,6 @@ public class LoginFragment extends Fragment implements ClickCallback {
Preferences.setToken(null); Preferences.setToken(null);
Preferences.setSalt(null); Preferences.setSalt(null);
Preferences.setLowSecurity(false); Preferences.setLowSecurity(false);
Preferences.setClientCert(null);
App.getSubsonicClientInstance(true); App.getSubsonicClientInstance(true);
} }

View file

@ -3,7 +3,6 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName; import android.content.ComponentName;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.text.TextUtils;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -174,54 +173,25 @@ public class PlayerBottomSheetFragment extends Fragment {
playerBottomSheetViewModel.setLiveArtist(getViewLifecycleOwner(), mediaMetadata.extras.getString("type"), mediaMetadata.extras.getString("artistId")); playerBottomSheetViewModel.setLiveArtist(getViewLifecycleOwner(), mediaMetadata.extras.getString("type"), mediaMetadata.extras.getString("artistId"));
playerBottomSheetViewModel.setLiveDescription(mediaMetadata.extras.getString("description", null)); playerBottomSheetViewModel.setLiveDescription(mediaMetadata.extras.getString("description", null));
String type = mediaMetadata.extras.getString("type");
if (Objects.equals(type, Constants.MEDIA_TYPE_RADIO)) {
// For radio: keep header consistent with full player
String stationName = mediaMetadata.extras.getString(
"stationName",
mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : ""
);
String artist = mediaMetadata.extras.getString("radioArtist", "");
String title = mediaMetadata.extras.getString("radioTitle", "");
String mainTitle;
if (!TextUtils.isEmpty(artist) && !TextUtils.isEmpty(title)) {
mainTitle = artist + " - " + title;
} else if (!TextUtils.isEmpty(title)) {
mainTitle = title;
} else if (!TextUtils.isEmpty(artist)) {
mainTitle = artist;
} else {
mainTitle = stationName;
}
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setText(mainTitle);
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setText(stationName);
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setVisibility(!TextUtils.isEmpty(mainTitle) ? View.VISIBLE : View.GONE);
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setVisibility(!TextUtils.isEmpty(stationName) ? View.VISIBLE : View.GONE);
} else {
// Default (music, podcast, etc.)
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setText(mediaMetadata.extras.getString("title")); bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setText(mediaMetadata.extras.getString("title"));
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setText( bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setText(
mediaMetadata.artist != null mediaMetadata.artist != null
? mediaMetadata.artist ? mediaMetadata.artist
: "" : Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO)
); ? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder))
: "");
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setVisibility(mediaMetadata.extras.getString("title") != null && !Objects.equals(mediaMetadata.extras.getString("title"), "") ? View.VISIBLE : View.GONE);
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setVisibility(
mediaMetadata.extras.getString("artist") != null && !Objects.equals(mediaMetadata.extras.getString("artist"), "")
? View.VISIBLE
: View.GONE);
}
CustomGlideRequest.Builder CustomGlideRequest.Builder
.from(requireContext(), mediaMetadata.extras.getString("coverArtId"), CustomGlideRequest.ResourceType.Song) .from(requireContext(), mediaMetadata.extras.getString("coverArtId"), CustomGlideRequest.ResourceType.Song)
.build() .build()
.into(bind.playerHeaderLayout.playerHeaderMediaCoverImage); .into(bind.playerHeaderLayout.playerHeaderMediaCoverImage);
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setVisibility(mediaMetadata.extras.getString("title") != null && !Objects.equals(mediaMetadata.extras.getString("title"), "") ? View.VISIBLE : View.GONE);
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setVisibility(
(mediaMetadata.extras.getString("artist") != null && !Objects.equals(mediaMetadata.extras.getString("artist"), ""))
|| (Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) && mediaMetadata.extras.getString("uri") != null)
? View.VISIBLE
: View.GONE);
} }
} }

View file

@ -7,12 +7,9 @@ import android.content.ServiceConnection;
import android.os.Bundle; import android.os.Bundle;
import android.os.IBinder; import android.os.IBinder;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log;
import android.view.Gravity;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.widget.Button; import android.widget.Button;
import android.widget.ImageButton; import android.widget.ImageButton;
import android.widget.LinearLayout; import android.widget.LinearLayout;
@ -35,10 +32,6 @@ import androidx.media3.session.SessionToken;
import androidx.navigation.NavController; import androidx.navigation.NavController;
import androidx.navigation.NavOptions; import androidx.navigation.NavOptions;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
import androidx.transition.ChangeBounds;
import androidx.transition.Slide;
import androidx.transition.TransitionManager;
import androidx.transition.TransitionSet;
import androidx.viewpager2.widget.ViewPager2; import androidx.viewpager2.widget.ViewPager2;
import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.R;
@ -46,7 +39,6 @@ import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerControllerBindi
import com.cappielloantonio.tempo.service.EqualizerManager; import com.cappielloantonio.tempo.service.EqualizerManager;
import com.cappielloantonio.tempo.service.MediaService; import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.dialog.PlaybackSpeedDialog;
import com.cappielloantonio.tempo.ui.dialog.RatingDialog; import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
import com.cappielloantonio.tempo.ui.dialog.TrackInfoDialog; import com.cappielloantonio.tempo.ui.dialog.TrackInfoDialog;
import com.cappielloantonio.tempo.ui.fragment.pager.PlayerControllerHorizontalPager; import com.cappielloantonio.tempo.ui.fragment.pager.PlayerControllerHorizontalPager;
@ -62,6 +54,7 @@ import com.google.android.material.elevation.SurfaceColors;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.MoreExecutors;
import java.text.DecimalFormat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@ -220,53 +213,12 @@ public class PlayerControllerFragment extends Fragment {
} }
private void setMetadata(MediaMetadata mediaMetadata) { private void setMetadata(MediaMetadata mediaMetadata) {
String type = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type") : null;
if (Objects.equals(type, Constants.MEDIA_TYPE_RADIO)) {
// For radio: always read from extras first (radioArtist, radioTitle, stationName)
// MediaMetadata.title/artist are formatted for notification
String stationName = mediaMetadata.extras != null
? mediaMetadata.extras.getString("stationName",
mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "")
: mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "";
String artist = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioArtist", "")
: "";
String title = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioTitle", "")
: "";
// Format: "Artist - Song" or fallback to title or station name
String mainTitle;
if (!TextUtils.isEmpty(artist) && !TextUtils.isEmpty(title)) {
mainTitle = artist + " - " + title;
} else if (!TextUtils.isEmpty(title)) {
mainTitle = title;
} else if (!TextUtils.isEmpty(artist)) {
mainTitle = artist;
} else {
mainTitle = stationName;
}
playerMediaTitleLabel.setText(mainTitle);
playerArtistNameLabel.setText(stationName);
playerMediaTitleLabel.setSelected(true);
playerArtistNameLabel.setSelected(true);
playerMediaTitleLabel.setVisibility(!TextUtils.isEmpty(mainTitle) ? View.VISIBLE : View.GONE);
playerArtistNameLabel.setVisibility(!TextUtils.isEmpty(stationName) ? View.VISIBLE : View.GONE);
updateAssetLinkChips(mediaMetadata);
return;
}
playerMediaTitleLabel.setText(String.valueOf(mediaMetadata.title)); playerMediaTitleLabel.setText(String.valueOf(mediaMetadata.title));
playerArtistNameLabel.setText( playerArtistNameLabel.setText(
mediaMetadata.artist != null mediaMetadata.artist != null
? String.valueOf(mediaMetadata.artist) ? String.valueOf(mediaMetadata.artist)
: mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO)
? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder))
: ""); : "");
playerMediaTitleLabel.setSelected(true); playerMediaTitleLabel.setSelected(true);
@ -283,80 +235,41 @@ public class PlayerControllerFragment extends Fragment {
} }
private void setMediaInfo(MediaMetadata mediaMetadata) { private void setMediaInfo(MediaMetadata mediaMetadata) {
boolean isLocal = false;
if (mediaBrowserListenableFuture != null && mediaBrowserListenableFuture.isDone()) {
try {
MediaBrowser browser = mediaBrowserListenableFuture.get();
if (browser != null && browser.getCurrentMediaItem() != null) {
android.net.Uri currentUri = browser.getCurrentMediaItem().requestMetadata.mediaUri;
if (currentUri != null) {
String scheme = currentUri.getScheme();
isLocal = "content".equals(scheme) || "file".equals(scheme);
}
}
} catch (Exception e) {
Log.e("DEBUG_PLAYER", "Error getting browser for UI update", e);
}
}
if (mediaMetadata.extras != null) { if (mediaMetadata.extras != null) {
String extension = mediaMetadata.extras.getString("suffix", getString(R.string.player_unknown_format)); String extension = mediaMetadata.extras.getString("suffix", getString(R.string.player_unknown_format));
int rawBitrate = mediaMetadata.extras.getInt("bitrate", 0); String bitrate = mediaMetadata.extras.getInt("bitrate", 0) != 0 ? mediaMetadata.extras.getInt("bitrate", 0) + "kbps" : "Original";
String bitrate = rawBitrate != 0 ? rawBitrate + "kbps" : "Original"; String samplingRate = mediaMetadata.extras.getInt("samplingRate", 0) != 0 ? new DecimalFormat("0.#").format(mediaMetadata.extras.getInt("samplingRate", 0) / 1000.0) + "kHz" : "";
String samplingRate = mediaMetadata.extras.getInt("samplingRate", 0) != 0 ?
new java.text.DecimalFormat("0.#").format(mediaMetadata.extras.getInt("samplingRate", 0) / 1000.0) + "kHz" : "";
String bitDepth = mediaMetadata.extras.getInt("bitDepth", 0) != 0 ? mediaMetadata.extras.getInt("bitDepth", 0) + "b" : ""; String bitDepth = mediaMetadata.extras.getInt("bitDepth", 0) != 0 ? mediaMetadata.extras.getInt("bitDepth", 0) + "b" : "";
playerMediaExtension.setText(extension); playerMediaExtension.setText(extension);
if (bitrate.equals("Original") && !isLocal) { if (bitrate.equals("Original")) {
playerMediaBitrate.setVisibility(View.GONE); playerMediaBitrate.setVisibility(View.GONE);
} else { } else {
List<String> items = new ArrayList<>(); List<String> mediaQualityItems = new ArrayList<>();
if (!bitrate.trim().isEmpty()) items.add(bitrate);
if (!bitDepth.trim().isEmpty()) items.add(bitDepth);
if (!samplingRate.trim().isEmpty()) items.add(samplingRate);
String mediaQuality = TextUtils.join("", items);
playerMediaBitrate.setVisibility(Preferences.getBitrateVisible() ? View.VISIBLE : View.GONE); if (!bitrate.trim().isEmpty()) mediaQualityItems.add(bitrate);
playerMediaBitrate.setText(isLocal ? mediaQuality : mediaQuality); if (!bitDepth.trim().isEmpty()) mediaQualityItems.add(bitDepth);
if (!samplingRate.trim().isEmpty()) mediaQualityItems.add(samplingRate);
String mediaQuality = TextUtils.join("", mediaQualityItems);
playerMediaBitrate.setVisibility(View.VISIBLE);
playerMediaBitrate.setText(mediaQuality);
} }
} }
if (!isLocal) {
boolean isTranscodingExtension = !MusicUtil.getTranscodingFormatPreference().equals("raw"); boolean isTranscodingExtension = !MusicUtil.getTranscodingFormatPreference().equals("raw");
boolean isTranscodingBitrate = !MusicUtil.getBitratePreference().equals("0"); boolean isTranscodingBitrate = !MusicUtil.getBitratePreference().equals("0");
if (isTranscodingExtension || isTranscodingBitrate) { if (isTranscodingExtension || isTranscodingBitrate) {
playerMediaExtension.setText(MusicUtil.getTranscodingFormatPreference() + " (" + getString(R.string.player_transcoding) + ")"); playerMediaExtension.setText(MusicUtil.getTranscodingFormatPreference() + " (" + getString(R.string.player_transcoding) + ")");
playerMediaBitrate.setText(!MusicUtil.getBitratePreference().equals("0") ? playerMediaBitrate.setText(!MusicUtil.getBitratePreference().equals("0") ? MusicUtil.getBitratePreference() + "kbps" : getString(R.string.player_transcoding_requested));
MusicUtil.getBitratePreference() + "kbps" : getString(R.string.player_transcoding_requested));
}
} }
playerTrackInfo.setOnClickListener(view -> { playerTrackInfo.setOnClickListener(view -> {
TrackInfoDialog dialog = new TrackInfoDialog(mediaMetadata); TrackInfoDialog dialog = new TrackInfoDialog(mediaMetadata);
dialog.show(activity.getSupportFragmentManager(), null); dialog.show(activity.getSupportFragmentManager(), null);
}); });
playerMediaExtension.setOnClickListener( v -> toggleBitrateVisibility() );
playerMediaBitrate.setOnClickListener(v -> toggleBitrateVisibility() );
}
private void toggleBitrateVisibility() {
ViewGroup parent = (ViewGroup) playerMediaBitrate.getParent();
TransitionSet transition = new TransitionSet()
.addTransition(new Slide(Gravity.START))
.addTransition(new ChangeBounds())
.setDuration(500)
.setInterpolator(new AccelerateDecelerateInterpolator());
TransitionManager.beginDelayedTransition(parent, transition);
playerMediaBitrate.setVisibility(Preferences.getBitrateVisible() ? View.GONE : View.VISIBLE);
Preferences.setBitrateVisible(!Preferences.getBitrateVisible());
} }
private void updateAssetLinkChips(MediaMetadata mediaMetadata) { private void updateAssetLinkChips(MediaMetadata mediaMetadata) {
@ -500,10 +413,10 @@ public class PlayerControllerFragment extends Fragment {
bind.getRoot().setShowNextButton(true); bind.getRoot().setShowNextButton(true);
bind.getRoot().setShowFastForwardButton(false); bind.getRoot().setShowFastForwardButton(false);
bind.getRoot().setRepeatToggleModes(RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL | RepeatModeUtil.REPEAT_TOGGLE_MODE_ONE); bind.getRoot().setRepeatToggleModes(RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL | RepeatModeUtil.REPEAT_TOGGLE_MODE_ONE);
bind.getRoot().findViewById(R.id.player_playback_speed_button).setVisibility(View.VISIBLE); bind.getRoot().findViewById(R.id.player_playback_speed_button).setVisibility(View.GONE);
bind.getRoot().findViewById(R.id.player_skip_silence_toggle_button).setVisibility(View.GONE); bind.getRoot().findViewById(R.id.player_skip_silence_toggle_button).setVisibility(View.GONE);
bind.getRoot().findViewById(R.id.button_favorite).setVisibility(View.VISIBLE); bind.getRoot().findViewById(R.id.button_favorite).setVisibility(View.VISIBLE);
setPlaybackParameters(mediaBrowser); resetPlaybackParameters(mediaBrowser);
break; break;
} }
} }
@ -609,12 +522,33 @@ public class PlayerControllerFragment extends Fragment {
private void initPlaybackSpeedButton(MediaBrowser mediaBrowser) { private void initPlaybackSpeedButton(MediaBrowser mediaBrowser) {
playbackSpeedButton.setOnClickListener(view -> { playbackSpeedButton.setOnClickListener(view -> {
PlaybackSpeedDialog dialog = new PlaybackSpeedDialog(); float currentSpeed = Preferences.getPlaybackSpeed();
dialog.setPlaybackSpeedListener(speed -> {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(speed)); if (currentSpeed == Constants.MEDIA_PLAYBACK_SPEED_080) {
playbackSpeedButton.setText(getString(R.string.player_playback_speed, speed)); mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_100));
}); playbackSpeedButton.setText(getString(R.string.player_playback_speed, Constants.MEDIA_PLAYBACK_SPEED_100));
dialog.show(requireActivity().getSupportFragmentManager(), null); Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_100);
} else if (currentSpeed == Constants.MEDIA_PLAYBACK_SPEED_100) {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_125));
playbackSpeedButton.setText(getString(R.string.player_playback_speed, Constants.MEDIA_PLAYBACK_SPEED_125));
Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_125);
} else if (currentSpeed == Constants.MEDIA_PLAYBACK_SPEED_125) {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_150));
playbackSpeedButton.setText(getString(R.string.player_playback_speed, Constants.MEDIA_PLAYBACK_SPEED_150));
Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_150);
} else if (currentSpeed == Constants.MEDIA_PLAYBACK_SPEED_150) {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_175));
playbackSpeedButton.setText(getString(R.string.player_playback_speed, Constants.MEDIA_PLAYBACK_SPEED_175));
Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_175);
} else if (currentSpeed == Constants.MEDIA_PLAYBACK_SPEED_175) {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_200));
playbackSpeedButton.setText(getString(R.string.player_playback_speed, Constants.MEDIA_PLAYBACK_SPEED_200));
Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_200);
} else if (currentSpeed == Constants.MEDIA_PLAYBACK_SPEED_200) {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_080));
playbackSpeedButton.setText(getString(R.string.player_playback_speed, Constants.MEDIA_PLAYBACK_SPEED_080));
Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_080);
}
}); });
skipSilenceToggleButton.setOnClickListener(view -> { skipSilenceToggleButton.setOnClickListener(view -> {
@ -666,7 +600,7 @@ public class PlayerControllerFragment extends Fragment {
} }
private void resetPlaybackParameters(MediaBrowser mediaBrowser) { private void resetPlaybackParameters(MediaBrowser mediaBrowser) {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(1.0f)); mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_100));
// TODO Resettare lo skip del silenzio // TODO Resettare lo skip del silenzio
} }

View file

@ -7,9 +7,7 @@ import android.os.Handler;
import android.text.Layout; import android.text.Layout;
import android.text.Spannable; import android.text.Spannable;
import android.text.SpannableString; import android.text.SpannableString;
import android.text.TextPaint; import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.ForegroundColorSpan; import android.text.style.ForegroundColorSpan;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
@ -53,7 +51,6 @@ public class PlayerLyricsFragment extends Fragment {
private Runnable syncLyricsRunnable; private Runnable syncLyricsRunnable;
private String currentLyrics; private String currentLyrics;
private LyricsList currentLyricsList; private LyricsList currentLyricsList;
private Integer lastLineIdx;
private String currentDescription; private String currentDescription;
@Override @Override
@ -112,7 +109,6 @@ public class PlayerLyricsFragment extends Fragment {
currentLyrics = null; currentLyrics = null;
currentLyricsList = null; currentLyricsList = null;
currentDescription = null; currentDescription = null;
lastLineIdx = null;
} }
private void initOverlay() { private void initOverlay() {
@ -166,7 +162,6 @@ public class PlayerLyricsFragment extends Fragment {
playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> { playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
currentLyricsList = lyricsList; currentLyricsList = lyricsList;
lastLineIdx = null;
updatePanelContent(); updatePanelContent();
}); });
@ -199,7 +194,7 @@ public class PlayerLyricsFragment extends Fragment {
bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0); bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0);
if (hasStructuredLyrics(currentLyricsList)) { if (hasStructuredLyrics(currentLyricsList)) {
setSyncLyrics(currentLyricsList); setSyncLirics(currentLyricsList);
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE); bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
bind.emptyDescriptionImageView.setVisibility(View.GONE); bind.emptyDescriptionImageView.setVisibility(View.GONE);
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE); bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
@ -246,14 +241,14 @@ public class PlayerLyricsFragment extends Fragment {
} }
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
private void setSyncLyrics(LyricsList lyricsList) { private void setSyncLirics(LyricsList lyricsList) {
if (lyricsList.getStructuredLyrics() != null && !lyricsList.getStructuredLyrics().isEmpty() && lyricsList.getStructuredLyrics().get(0).getLine() != null) { if (lyricsList.getStructuredLyrics() != null && !lyricsList.getStructuredLyrics().isEmpty() && lyricsList.getStructuredLyrics().get(0).getLine() != null) {
StringBuilder lyricsBuilder = new StringBuilder(); StringBuilder lyricsBuilder = new StringBuilder();
List<Line> lines = lyricsList.getStructuredLyrics().get(0).getLine(); List<Line> lines = lyricsList.getStructuredLyrics().get(0).getLine();
if (lines != null) { if (lines != null) {
for (Line line : lines) { for (Line line : lines) {
lyricsBuilder.append(line.getValue().trim()).append("\n\n"); lyricsBuilder.append(line.getValue().trim()).append("\n");
} }
} }
@ -293,75 +288,67 @@ public class PlayerLyricsFragment extends Fragment {
int timestamp = (int) (mediaBrowser.getCurrentPosition()); int timestamp = (int) (mediaBrowser.getCurrentPosition());
if (hasStructuredLyrics(lyricsList)) { if (hasStructuredLyrics(lyricsList)) {
StringBuilder lyricsBuilder = new StringBuilder();
List<Line> lines = lyricsList.getStructuredLyrics().get(0).getLine(); List<Line> lines = lyricsList.getStructuredLyrics().get(0).getLine();
if (lines == null || lines.isEmpty()) {
return; if (lines == null || lines.isEmpty()) return;
for (Line line : lines) {
lyricsBuilder.append(line.getValue().trim()).append("\n");
} }
// Find the index of the currently playing line Line toHighlight = lines.stream().filter(line -> line != null && line.getStart() != null && line.getStart() < timestamp).reduce((first, second) -> second).orElse(null);
int curIdx = 0;
for (; curIdx < lines.size(); ++curIdx) { if (toHighlight != null) {
Integer start = lines.get(curIdx).getStart(); String lyrics = lyricsBuilder.toString();
if (start != null && start > timestamp) { Spannable spannableString = new SpannableString(lyrics);
curIdx--; // Found the first line that starts after the current timestamp
int startingPosition = getStartPosition(lines, toHighlight);
int endingPosition = startingPosition + toHighlight.getValue().length();
spannableString.setSpan(new ForegroundColorSpan(requireContext().getResources().getColor(R.color.shadowsLyricsTextColor, null)), 0, lyrics.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spannableString.setSpan(new ForegroundColorSpan(requireContext().getResources().getColor(R.color.lyricsTextColor, null)), startingPosition, endingPosition, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
bind.nowPlayingSongLyricsTextView.setText(spannableString);
if (playerBottomSheetViewModel.getSyncLyricsState()) {
bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, getScroll(lines, toHighlight));
}
}
}
}
private int getStartPosition(List<Line> lines, Line toHighlight) {
int start = 0;
for (Line line : lines) {
if (line != toHighlight) {
start = start + line.getValue().length() + 1;
} else {
break; break;
} }
} }
// Only update if the highlighted line has changed return start;
if (lastLineIdx != null && curIdx == lastLineIdx) {
return;
} }
lastLineIdx = curIdx;
StringBuilder lyricsBuilder = new StringBuilder(); private int getLineCount(List<Line> lines, Line toHighlight) {
int start = 0;
for (Line line : lines) { for (Line line : lines) {
lyricsBuilder.append(line.getValue().trim()).append("\n\n"); if (line != toHighlight) {
} bind.tempLyricsLineTextView.setText(line.getValue());
String lyrics = lyricsBuilder.toString(); start = start + bind.tempLyricsLineTextView.getLineCount();
Spannable spannableString = new SpannableString(lyrics);
// Make each line clickable for navigation and highlight the current one
int offset = 0;
int highlightStart = -1;
for (int i = 0; i < lines.size(); ++i) {
boolean highlight = i == curIdx;
if (highlight) highlightStart = offset;
int len = lines.get(i).getValue().length() + 2;
final int lineStart = lines.get(i).getStart();
spannableString.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull View view) {
// Seeking to 1ms after the actual start prevents scrolling / highlighting artifacts
mediaBrowser.seekTo(lineStart + 1);
}
@Override
public void updateDrawState(@NonNull TextPaint ds) {
super.updateDrawState(ds);
ds.setUnderlineText(false);
if (highlight) {
ds.setColor(requireContext().getResources().getColor(R.color.lyricsTextColor, null));
} else { } else {
ds.setColor(requireContext().getResources().getColor(R.color.shadowsLyricsTextColor, null)); break;
}
}
}, offset, offset + len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
offset += len;
}
bind.nowPlayingSongLyricsTextView.setMovementMethod(LinkMovementMethod.getInstance());
bind.nowPlayingSongLyricsTextView.setText(spannableString);
// Scroll to the highlighted line, but only if there is one
if (highlightStart >= 0 && playerBottomSheetViewModel.getSyncLyricsState()) {
bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, getScroll(highlightStart));
}
} }
} }
private int getScroll(int startIndex) { return start;
}
private int getScroll(List<Line> lines, Line toHighlight) {
int startIndex = getStartPosition(lines, toHighlight);
Layout layout = bind.nowPlayingSongLyricsTextView.getLayout(); Layout layout = bind.nowPlayingSongLyricsTextView.getLayout();
if (layout == null) return 0; if (layout == null) return 0;

View file

@ -2,41 +2,28 @@ package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName; import android.content.ComponentName;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler;
import android.util.Log; import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider; import androidx.lifecycle.ViewModelProvider;
import androidx.lifecycle.Observer;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser; import androidx.media3.session.MediaBrowser;
import androidx.media3.common.MediaItem;
import androidx.media3.session.SessionToken; import androidx.media3.session.SessionToken;
import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerQueueBinding; import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerQueueBinding;
import com.cappielloantonio.tempo.interfaces.ClickCallback; import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.service.DownloaderManager;
import com.cappielloantonio.tempo.service.MediaManager; import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService; import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.PlayQueue;
import com.cappielloantonio.tempo.ui.adapter.PlayerSongQueueAdapter; import com.cappielloantonio.tempo.ui.adapter.PlayerSongQueueAdapter;
import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel; import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
@ -44,7 +31,6 @@ import com.google.common.util.concurrent.MoreExecutors;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@UnstableApi @UnstableApi
@ -53,18 +39,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
private InnerFragmentPlayerQueueBinding bind; private InnerFragmentPlayerQueueBinding bind;
private com.google.android.material.floatingactionbutton.FloatingActionButton fabMenuToggle;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabClearQueue;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabShuffleQueue;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabSaveToPlaylist;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabDownloadAll;
private com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton fabLoadQueue;
private boolean isMenuOpen = false;
private final int ANIMATION_DURATION = 250;
private final float FAB_VERTICAL_SPACING_DP = 70f;
private PlayerBottomSheetViewModel playerBottomSheetViewModel; private PlayerBottomSheetViewModel playerBottomSheetViewModel;
private PlaybackViewModel playbackViewModel; private PlaybackViewModel playbackViewModel;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture; private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@ -79,27 +53,6 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class); playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class); playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
fabMenuToggle = bind.fabMenuToggle;
fabClearQueue = bind.fabClearQueue;
fabShuffleQueue = bind.fabShuffleQueue;
fabSaveToPlaylist = bind.fabSaveToPlaylist;
fabDownloadAll = bind.fabDownloadAll;
fabLoadQueue = bind.fabLoadQueue;
fabMenuToggle.setOnClickListener(v -> toggleFabMenu());
fabClearQueue.setOnClickListener(v -> handleClearQueueClick());
fabShuffleQueue.setOnClickListener(v -> handleShuffleQueueClick());
fabSaveToPlaylist.setOnClickListener(v -> handleSaveToPlaylistClick());
fabDownloadAll.setOnClickListener(v -> handleDownloadAllClick());
fabLoadQueue.setOnClickListener(v -> handleLoadQueueClick());
// Hide Load Queue FAB if sync is disabled
if (!Preferences.isSyncronizationEnabled()) {
fabLoadQueue.setVisibility(View.GONE);
}
initQueueRecyclerView(); initQueueRecyclerView();
return view; return view;
@ -109,6 +62,8 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
public void onStart() { public void onStart() {
super.onStart(); super.onStart();
initializeBrowser(); initializeBrowser();
bindMediaController();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel); MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback(); observePlayback();
} }
@ -150,6 +105,18 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
MediaBrowser.releaseFuture(mediaBrowserListenableFuture); MediaBrowser.releaseFuture(mediaBrowserListenableFuture);
} }
private void bindMediaController() {
mediaBrowserListenableFuture.addListener(() -> {
try {
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
initShuffleButton(mediaBrowser);
initCleanButton(mediaBrowser);
} catch (Exception exception) {
exception.printStackTrace();
}
}, MoreExecutors.directExecutor());
}
private void setMediaBrowserListenableFuture() { private void setMediaBrowserListenableFuture() {
playerSongQueueAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); playerSongQueueAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
} }
@ -182,6 +149,18 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
fromPosition = viewHolder.getBindingAdapterPosition(); fromPosition = viewHolder.getBindingAdapterPosition();
toPosition = target.getBindingAdapterPosition(); toPosition = target.getBindingAdapterPosition();
/*
* Per spostare un elemento nella coda devo:
* - Spostare graficamente la traccia da una posizione all'altra con Collections.swap()
* - Spostare nel db la traccia, tramite QueueRepository
* - Notificare il Service dell'avvenuto spostamento con MusicPlayerRemote.moveSong()
*
* In onMove prendo la posizione di inizio e fine, ma solo al rilascio dell'elemento procedo allo spostamento
* In questo modo evito che ad ogni cambio di posizione vada a riscrivere nel db
* Al rilascio dell'elemento chiamo il metodo clearView()
*/
Collections.swap(playerSongQueueAdapter.getItems(), fromPosition, toPosition); Collections.swap(playerSongQueueAdapter.getItems(), fromPosition, toPosition);
recyclerView.getAdapter().notifyItemMoved(fromPosition, toPosition); recyclerView.getAdapter().notifyItemMoved(fromPosition, toPosition);
@ -209,6 +188,46 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
}).attachToRecyclerView(bind.playerQueueRecyclerView); }).attachToRecyclerView(bind.playerQueueRecyclerView);
} }
private void initShuffleButton(MediaBrowser mediaBrowser) {
bind.playerShuffleQueueFab.setOnClickListener(view -> {
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
int endPosition = playerSongQueueAdapter.getItems().size() - 1;
if (startPosition < endPosition) {
ArrayList<Integer> pool = new ArrayList<>();
for (int i = startPosition; i <= endPosition; i++) {
pool.add(i);
}
while (pool.size() >= 2) {
int fromPosition = (int) (Math.random() * (pool.size()));
int positionA = pool.get(fromPosition);
pool.remove(fromPosition);
int toPosition = (int) (Math.random() * (pool.size()));
int positionB = pool.get(toPosition);
pool.remove(toPosition);
Collections.swap(playerSongQueueAdapter.getItems(), positionA, positionB);
bind.playerQueueRecyclerView.getAdapter().notifyItemMoved(positionA, positionB);
}
MediaManager.shuffle(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
}
});
}
private void initCleanButton(MediaBrowser mediaBrowser) {
bind.playerCleanQueueButton.setOnClickListener(view -> {
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
int endPosition = playerSongQueueAdapter.getItems().size();
MediaManager.removeRange(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
bind.playerQueueRecyclerView.getAdapter().notifyItemRangeRemoved(startPosition, endPosition);
});
}
private void updateNowPlayingItem() { private void updateNowPlayingItem() {
playerSongQueueAdapter.notifyDataSetChanged(); playerSongQueueAdapter.notifyDataSetChanged();
} }
@ -240,250 +259,4 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing); playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
} }
} }
/**
* Toggles the visibility and animates all six secondary FABs.
*/
private void toggleFabMenu() {
if (isMenuOpen) {
// CLOSE MENU (Reverse order for visual effect)
if (Preferences.isSyncronizationEnabled()) {
closeFab(fabLoadQueue, 4);
}
closeFab(fabSaveToPlaylist, 3);
closeFab(fabClearQueue, 2);
closeFab(fabDownloadAll, 1);
closeFab(fabShuffleQueue, 0);
fabMenuToggle.animate().rotation(0f).setDuration(ANIMATION_DURATION).start();
} else {
// OPEN MENU (lowest index at bottom)
openFab(fabShuffleQueue, 0);
openFab(fabDownloadAll, 1);
openFab(fabClearQueue, 2);
openFab(fabSaveToPlaylist, 3);
if (Preferences.isSyncronizationEnabled()) {
openFab(fabLoadQueue, 4);
}
fabMenuToggle.animate().rotation(45f).setDuration(ANIMATION_DURATION).start();
}
isMenuOpen = !isMenuOpen;
}
private void openFab(View fab, int index) {
final float displacement = getResources().getDisplayMetrics().density * (FAB_VERTICAL_SPACING_DP * (index + 1));
fab.setVisibility(View.VISIBLE);
fab.setAlpha(0f);
fab.setTranslationY(displacement); // Start at the hidden (closed) position
fab.animate()
.translationY(0f)
.alpha(1f)
.setDuration(ANIMATION_DURATION)
.start();
}
private void closeFab(View fab, int index) {
final float displacement = getResources().getDisplayMetrics().density * (FAB_VERTICAL_SPACING_DP * (index + 1));
fab.animate()
.translationY(displacement)
.alpha(0f)
.setDuration(ANIMATION_DURATION)
.withEndAction(() -> fab.setVisibility(View.GONE))
.start();
}
private void handleShuffleQueueClick() {
Log.d(TAG, "Shuffle Queue Clicked!");
mediaBrowserListenableFuture.addListener(() -> {
try {
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
int endPosition = playerSongQueueAdapter.getItems().size() - 1;
if (startPosition < endPosition) {
ArrayList<Integer> pool = new ArrayList<>();
for (int i = startPosition; i <= endPosition; i++) {
pool.add(i);
}
while (pool.size() >= 2) {
int fromPosition = (int) (Math.random() * (pool.size()));
int positionA = pool.get(fromPosition);
pool.remove(fromPosition);
int toPosition = (int) (Math.random() * (pool.size()));
int positionB = pool.get(toPosition);
pool.remove(toPosition);
Collections.swap(playerSongQueueAdapter.getItems(), positionA, positionB);
bind.playerQueueRecyclerView.getAdapter().notifyItemMoved(positionA, positionB);
}
MediaManager.shuffle(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
}
} catch (Exception e) {
Log.e(TAG, "Error shuffling queue", e);
}
toggleFabMenu();
}, MoreExecutors.directExecutor());
}
private void handleClearQueueClick() {
Log.d(TAG, "Clear Queue Clicked!");
mediaBrowserListenableFuture.addListener(() -> {
try {
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
int startPosition = mediaBrowser.getCurrentMediaItemIndex() + 1;
int endPosition = playerSongQueueAdapter.getItems().size();
MediaManager.removeRange(mediaBrowserListenableFuture, playerSongQueueAdapter.getItems(), startPosition, endPosition);
bind.playerQueueRecyclerView.getAdapter().notifyItemRangeRemoved(startPosition, endPosition - startPosition);
} catch (Exception e) {
Log.e(TAG, "Error clearing queue", e);
}
toggleFabMenu();
}, MoreExecutors.directExecutor());
}
private void handleSaveToPlaylistClick() {
Log.d(TAG, "Save to Playlist Clicked!");
List<Child> queueSongs = playerSongQueueAdapter.getItems();
if (queueSongs == null || queueSongs.isEmpty()) {
Toast.makeText(requireContext(), "Queue is empty", Toast.LENGTH_SHORT).show();
toggleFabMenu();
return;
}
Bundle bundle = new Bundle();
bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(queueSongs));
PlaylistChooserDialog dialog = new PlaylistChooserDialog();
dialog.setArguments(bundle);
dialog.show(requireActivity().getSupportFragmentManager(), null);
toggleFabMenu();
}
private void handleDownloadAllClick() {
Log.d(TAG, "Download All Clicked!");
List<Child> queueSongs = playerSongQueueAdapter.getItems();
if (queueSongs == null || queueSongs.isEmpty()) {
Toast.makeText(requireContext(), "Queue is empty", Toast.LENGTH_SHORT).show();
toggleFabMenu();
return;
}
int downloadCount = 0;
if (Preferences.getDownloadDirectoryUri() == null) {
List<MediaItem> mediaItemsToDownload = MappingUtil.mapMediaItems(queueSongs);
List<com.cappielloantonio.tempo.model.Download> downloadModels = new ArrayList<>();
for (Child child : queueSongs) {
com.cappielloantonio.tempo.model.Download downloadModel =
new com.cappielloantonio.tempo.model.Download(child);
downloadModel.setArtist(child.getArtist());
downloadModel.setAlbum(child.getAlbum());
downloadModel.setCoverArtId(child.getCoverArtId());
downloadModels.add(downloadModel);
}
DownloaderManager downloaderManager = DownloadUtil.getDownloadTracker(requireContext());
if (downloaderManager != null) {
downloaderManager.download(mediaItemsToDownload, downloadModels);
downloadCount = queueSongs.size();
Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, downloadCount, downloadCount),
Toast.LENGTH_SHORT).show();
new Handler().postDelayed(() -> {
if (playerSongQueueAdapter != null) {
playerSongQueueAdapter.notifyDataSetChanged();
}
}, 1000);
} else {
Log.e(TAG, "DownloaderManager not initialized. Check DownloadUtil.");
Toast.makeText(requireContext(), "Download service unavailable.", Toast.LENGTH_SHORT).show();
}
} else {
for (Child song : queueSongs) {
if (ExternalAudioReader.getUri(song) == null) {
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
downloadCount++;
}
}
if (downloadCount > 0) {
Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, downloadCount, downloadCount),
Toast.LENGTH_SHORT).show();
new Handler().postDelayed(() -> {
if (playerSongQueueAdapter != null) {
playerSongQueueAdapter.notifyDataSetChanged();
}
}, 2000);
} else {
Toast.makeText(requireContext(), "All songs already downloaded", Toast.LENGTH_SHORT).show();
}
}
toggleFabMenu();
}
private void handleLoadQueueClick() {
Log.d(TAG, "Load Queue Clicked!");
if (!Preferences.isSyncronizationEnabled()) {
toggleFabMenu();
return;
}
PlayerBottomSheetViewModel playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
playerBottomSheetViewModel.getPlayQueue().observe(getViewLifecycleOwner(), new Observer<PlayQueue>() {
@Override
public void onChanged(PlayQueue playQueue) {
playerBottomSheetViewModel.getPlayQueue().removeObserver(this);
if (playQueue != null && playQueue.getEntries() != null && !playQueue.getEntries().isEmpty()) {
int currentIndex = 0;
for (int i = 0; i < playQueue.getEntries().size(); i++) {
if (playQueue.getEntries().get(i).getId().equals(playQueue.getCurrent())) {
currentIndex = i;
break;
}
}
MediaManager.startQueue(mediaBrowserListenableFuture, playQueue.getEntries(), currentIndex);
Toast.makeText(requireContext(), "Queue loaded", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(requireContext(), "No saved queue found", Toast.LENGTH_SHORT).show();
}
toggleFabMenu();
}
});
new Handler().postDelayed(() -> {
if (isMenuOpen) {
toggleFabMenu();
}
}, 1000);
}
} }

View file

@ -156,10 +156,10 @@ public class PlaylistCatalogueFragment extends Fragment implements ClickCallback
popup.setOnMenuItemClickListener(menuItem -> { popup.setOnMenuItemClickListener(menuItem -> {
if (menuItem.getItemId() == R.id.menu_playlist_sort_name) { if (menuItem.getItemId() == R.id.menu_playlist_sort_name) {
playlistHorizontalAdapter.sort(Constants.PLAYLIST_ORDER_BY_NAME); playlistHorizontalAdapter.sort(Constants.GENRE_ORDER_BY_NAME);
return true; return true;
} else if (menuItem.getItemId() == R.id.menu_playlist_sort_random) { } else if (menuItem.getItemId() == R.id.menu_playlist_sort_random) {
playlistHorizontalAdapter.sort(Constants.PLAYLIST_ORDER_BY_RANDOM); playlistHorizontalAdapter.sort(Constants.GENRE_ORDER_BY_RANDOM);
return true; return true;
} }

View file

@ -216,9 +216,8 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
}); });
bind.playlistPageShuffleButton.setOnClickListener(v -> { bind.playlistPageShuffleButton.setOnClickListener(v -> {
java.util.List<com.cappielloantonio.tempo.subsonic.models.Child> shuffledSongs = new java.util.ArrayList<>(songs); Collections.shuffle(songs);
java.util.Collections.shuffle(shuffledSongs); MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
MediaManager.startQueue(mediaBrowserListenableFuture, shuffledSongs, 0);
activity.setBottomSheetInPeek(true); activity.setBottomSheetInPeek(true);
}); });
} }
@ -228,33 +227,32 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
private void initBackCover() { private void initBackCover() {
playlistPageViewModel.getPlaylistSongLiveList().observe(requireActivity(), songs -> { playlistPageViewModel.getPlaylistSongLiveList().observe(requireActivity(), songs -> {
if (bind != null && songs != null && !songs.isEmpty()) { if (bind != null && songs != null && !songs.isEmpty()) {
java.util.List<com.cappielloantonio.tempo.subsonic.models.Child> randomSongs = new java.util.ArrayList<>(songs); Collections.shuffle(songs);
java.util.Collections.shuffle(randomSongs);
// Pic top-left // Pic top-left
CustomGlideRequest.Builder CustomGlideRequest.Builder
.from(requireContext(), !randomSongs.isEmpty() ? randomSongs.get(0).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song) .from(requireContext(), !songs.isEmpty() ? songs.get(0).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song)
.build() .build()
.transform(new GranularRoundedCorners(CustomGlideRequest.CORNER_RADIUS, 0, 0, 0)) .transform(new GranularRoundedCorners(CustomGlideRequest.CORNER_RADIUS, 0, 0, 0))
.into(bind.playlistCoverImageViewTopLeft); .into(bind.playlistCoverImageViewTopLeft);
// Pic top-right // Pic top-right
CustomGlideRequest.Builder CustomGlideRequest.Builder
.from(requireContext(), randomSongs.size() > 1 ? randomSongs.get(1).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song) .from(requireContext(), songs.size() > 1 ? songs.get(1).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song)
.build() .build()
.transform(new GranularRoundedCorners(0, CustomGlideRequest.CORNER_RADIUS, 0, 0)) .transform(new GranularRoundedCorners(0, CustomGlideRequest.CORNER_RADIUS, 0, 0))
.into(bind.playlistCoverImageViewTopRight); .into(bind.playlistCoverImageViewTopRight);
// Pic bottom-left // Pic bottom-left
CustomGlideRequest.Builder CustomGlideRequest.Builder
.from(requireContext(), randomSongs.size() > 2 ? randomSongs.get(2).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song) .from(requireContext(), songs.size() > 2 ? songs.get(2).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song)
.build() .build()
.transform(new GranularRoundedCorners(0, 0, 0, CustomGlideRequest.CORNER_RADIUS)) .transform(new GranularRoundedCorners(0, 0, 0, CustomGlideRequest.CORNER_RADIUS))
.into(bind.playlistCoverImageViewBottomLeft); .into(bind.playlistCoverImageViewBottomLeft);
// Pic bottom-right // Pic bottom-right
CustomGlideRequest.Builder CustomGlideRequest.Builder
.from(requireContext(), randomSongs.size() > 3 ? randomSongs.get(3).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song) .from(requireContext(), songs.size() > 3 ? songs.get(3).getCoverArtId() : playlistPageViewModel.getPlaylist().getCoverArtId(), CustomGlideRequest.ResourceType.Song)
.build() .build()
.transform(new GranularRoundedCorners(0, 0, CustomGlideRequest.CORNER_RADIUS, 0)) .transform(new GranularRoundedCorners(0, 0, CustomGlideRequest.CORNER_RADIUS, 0))
.into(bind.playlistCoverImageViewBottomRight); .into(bind.playlistCoverImageViewBottomRight);
@ -273,11 +271,6 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> { playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> {
songHorizontalAdapter.setItems(songs); songHorizontalAdapter.setItems(songs);
if (songs != null) {
bind.playlistSongCountLabel.setText(getString(R.string.playlist_song_count, songs.size()));
long totalDuration = songs.stream().mapToLong(s -> s.getDuration() != null ? s.getDuration() : 0).sum();
bind.playlistDurationLabel.setText(getString(R.string.playlist_duration, MusicUtil.getReadableDurationString(totalDuration, false)));
}
reapplyPlayback(); reapplyPlayback();
}); });
} }
@ -298,7 +291,6 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
@Override @Override
public void onMediaLongClick(Bundle bundle) { public void onMediaLongClick(Bundle bundle) {
bundle.putString(Constants.PLAYLIST_ID, playlistPageViewModel.getPlaylist().getId());
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle); Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
} }

View file

@ -9,8 +9,6 @@ import android.media.audiofx.AudioEffect;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.os.IBinder; import android.os.IBinder;
import android.text.InputFilter;
import android.text.InputType;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -29,7 +27,6 @@ import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController; import androidx.navigation.NavController;
import androidx.navigation.NavOptions; import androidx.navigation.NavOptions;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
import androidx.preference.EditTextPreference;
import androidx.preference.ListPreference; import androidx.preference.ListPreference;
import androidx.preference.Preference; import androidx.preference.Preference;
import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceCategory;
@ -130,8 +127,6 @@ public class SettingsFragment extends PreferenceFragmentCompat {
super.onStart(); super.onStart();
activity.setBottomNavigationBarVisibility(false); activity.setBottomNavigationBarVisibility(false);
activity.setBottomSheetVisibility(false); activity.setBottomSheetVisibility(false);
activity.setNavigationDrawerLock(true);
activity.setSystemBarsVisibility(!activity.isLandscape);
} }
@Override @Override
@ -146,7 +141,6 @@ public class SettingsFragment extends PreferenceFragmentCompat {
setStreamingCacheSize(); setStreamingCacheSize();
setAppLanguage(); setAppLanguage();
setVersion(); setVersion();
setNetorkPingTimeoutBase();
actionLogout(); actionLogout();
actionScan(); actionScan();
@ -169,8 +163,6 @@ public class SettingsFragment extends PreferenceFragmentCompat {
public void onStop() { public void onStop() {
super.onStop(); super.onStop();
activity.setBottomSheetVisibility(true); activity.setBottomSheetVisibility(true);
activity.toggleNavigationDrawerLockOnOrientationChange();
activity.setSystemBarsVisibility(!activity.isLandscape);
} }
@Override @Override
@ -269,30 +261,6 @@ public class SettingsFragment extends PreferenceFragmentCompat {
} }
} }
private void setNetorkPingTimeoutBase() {
EditTextPreference networkPingTimeoutBase = findPreference("network_ping_timeout_base");
if (networkPingTimeoutBase != null) {
networkPingTimeoutBase.setSummaryProvider(EditTextPreference.SimpleSummaryProvider.getInstance());
networkPingTimeoutBase.setOnBindEditTextListener(editText -> {
editText.setInputType(InputType.TYPE_CLASS_NUMBER);
editText.setFilters(new InputFilter[]{ (source, start, end, dest, dstart, dend) -> {
for (int i = start; i < end; i++) {
if (!Character.isDigit(source.charAt(i))) {
return "";
}
}
return null;
}});
});
networkPingTimeoutBase.setOnPreferenceChangeListener((preference, newValue) -> {
String input = (String) newValue;
return input != null && !input.isEmpty();
});
}
}
private void setStreamingCacheSize() { private void setStreamingCacheSize() {
ListPreference streamingCachePreference = findPreference("streaming_cache_size"); ListPreference streamingCachePreference = findPreference("streaming_cache_size");

View file

@ -24,6 +24,7 @@ import androidx.navigation.fragment.NavHostFragment;
import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.glide.CustomGlideRequest; import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.interfaces.MediaCallback;
import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.repository.AlbumRepository; import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.service.MediaManager; import com.cappielloantonio.tempo.service.MediaManager;
@ -42,6 +43,7 @@ import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.viewmodel.AlbumBottomSheetViewModel; import com.cappielloantonio.tempo.viewmodel.AlbumBottomSheetViewModel;
import com.cappielloantonio.tempo.viewmodel.HomeViewModel; import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import com.google.android.material.snackbar.Snackbar;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList; import java.util.ArrayList;
@ -59,10 +61,7 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
private List<Child> currentAlbumTracks = Collections.emptyList(); private List<Child> currentAlbumTracks = Collections.emptyList();
private List<MediaItem> currentAlbumMediaItems = Collections.emptyList(); private List<MediaItem> currentAlbumMediaItems = Collections.emptyList();
private boolean isFirstBatch = true;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture; private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private static final String TAG = "AlbumBottomSheetDialog";
@Nullable @Nullable
@Override @Override
@ -115,41 +114,33 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite); ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
favoriteToggle.setChecked(albumBottomSheetViewModel.getAlbum().getStarred() != null); favoriteToggle.setChecked(albumBottomSheetViewModel.getAlbum().getStarred() != null);
favoriteToggle.setOnClickListener(v -> albumBottomSheetViewModel.setFavorite(requireContext())); favoriteToggle.setOnClickListener(v -> {
albumBottomSheetViewModel.setFavorite(requireContext());
});
TextView playRadio = view.findViewById(R.id.play_radio_text_view); TextView playRadio = view.findViewById(R.id.play_radio_text_view);
playRadio.setOnClickListener(v -> { playRadio.setOnClickListener(v -> {
MainActivity activity = (MainActivity) getActivity(); AlbumRepository albumRepository = new AlbumRepository();
if (activity == null) return; albumRepository.getInstantMix(album, 20, new MediaCallback() {
@Override
public void onError(Exception exception) {
exception.printStackTrace();
}
ListenableFuture<MediaBrowser> activityBrowserFuture = activity.getMediaBrowserListenableFuture(); @Override
if (activityBrowserFuture == null) return; public void onLoadMedia(List<?> media) {
MusicUtil.ratingFilter((ArrayList<Child>) media);
isFirstBatch = true; if (!media.isEmpty()) {
Toast.makeText(requireContext(), R.string.bottom_sheet_generating_instant_mix, Toast.LENGTH_SHORT).show(); MediaManager.startQueue(mediaBrowserListenableFuture, (ArrayList<Child>) media, 0);
((MainActivity) requireActivity()).setBottomSheetInPeek(true);
}
albumBottomSheetViewModel.getAlbumInstantMix(activity, album).observe(activity, media -> {
if (media == null || media.isEmpty()) return;
if (getActivity() == null) return;
MusicUtil.ratingFilter(media);
if (isFirstBatch) {
isFirstBatch = false;
MediaManager.startQueue(activityBrowserFuture, media, 0);
activity.setBottomSheetInPeek(true);
if (isAdded()) {
dismissBottomSheet(); dismissBottomSheet();
} }
} else {
MediaManager.enqueue(activityBrowserFuture, media, true);
}
}); });
}); });
TextView playRandom = view.findViewById(R.id.play_random_text_view); TextView playRandom = view.findViewById(R.id.play_random_text_view);
playRandom.setOnClickListener(v -> { playRandom.setOnClickListener(v -> {
AlbumRepository albumRepository = new AlbumRepository(); AlbumRepository albumRepository = new AlbumRepository();
@ -195,7 +186,8 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
}); });
TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view); TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view);
addToPlaylist.setOnClickListener(v -> albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> { addToPlaylist.setOnClickListener(v -> {
albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> {
Bundle bundle = new Bundle(); Bundle bundle = new Bundle();
bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(songs)); bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(songs));
@ -204,7 +196,8 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
dialog.show(requireActivity().getSupportFragmentManager(), null); dialog.show(requireActivity().getSupportFragmentManager(), null);
dismissBottomSheet(); dismissBottomSheet();
})); });
});
removeAllTextView = view.findViewById(R.id.remove_all_text_view); removeAllTextView = view.findViewById(R.id.remove_all_text_view);
albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> { albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> {
@ -298,5 +291,4 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
private void refreshShares() { private void refreshShares() {
homeViewModel.refreshShares(requireActivity()); homeViewModel.refreshShares(requireActivity());
} }
} }

View file

@ -29,7 +29,6 @@ import com.cappielloantonio.tempo.viewmodel.ArtistBottomSheetViewModel;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
@UnstableApi @UnstableApi
public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implements View.OnClickListener { public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implements View.OnClickListener {
private static final String TAG = "AlbumBottomSheetDialog"; private static final String TAG = "AlbumBottomSheetDialog";
@ -39,8 +38,6 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture; private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private boolean isFirstBatch = true;
@Nullable @Nullable
@Override @Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
@ -89,31 +86,20 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement
TextView playRadio = view.findViewById(R.id.play_radio_text_view); TextView playRadio = view.findViewById(R.id.play_radio_text_view);
playRadio.setOnClickListener(v -> { playRadio.setOnClickListener(v -> {
MainActivity activity = (MainActivity) getActivity(); ArtistRepository artistRepository = new ArtistRepository();
if (activity == null) return;
ListenableFuture<MediaBrowser> activityBrowserFuture = activity.getMediaBrowserListenableFuture(); artistRepository.getInstantMix(artist, 20).observe(getViewLifecycleOwner(), songs -> {
if (activityBrowserFuture == null) return; // navidrome may return null for this
if (songs == null)
return;
MusicUtil.ratingFilter(songs);
isFirstBatch = true; if (!songs.isEmpty()) {
Toast.makeText(requireContext(), R.string.bottom_sheet_generating_instant_mix, Toast.LENGTH_SHORT).show(); MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
((MainActivity) requireActivity()).setBottomSheetInPeek(true);
}
artistBottomSheetViewModel.getArtistInstantMix(activity, artist).observe(activity, media -> {
if (media == null || media.isEmpty()) return;
if (getActivity() == null) return;
MusicUtil.ratingFilter(media);
if (isFirstBatch) {
isFirstBatch = false;
MediaManager.startQueue(activityBrowserFuture, media, 0);
activity.setBottomSheetInPeek(true);
if (isAdded()) {
dismissBottomSheet(); dismissBottomSheet();
}
} else {
MediaManager.enqueue(activityBrowserFuture, media, true);
}
}); });
}); });
@ -122,10 +108,16 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement
ArtistRepository artistRepository = new ArtistRepository(); ArtistRepository artistRepository = new ArtistRepository();
artistRepository.getRandomSong(artist, 50).observe(getViewLifecycleOwner(), songs -> { artistRepository.getRandomSong(artist, 50).observe(getViewLifecycleOwner(), songs -> {
MusicUtil.ratingFilter(songs); MusicUtil.ratingFilter(songs);
if (!songs.isEmpty()) { if (!songs.isEmpty()) {
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0); MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
((MainActivity) requireActivity()).setBottomSheetInPeek(true); ((MainActivity) requireActivity()).setBottomSheetInPeek(true);
dismissBottomSheet();
} else {
Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_tracks), Toast.LENGTH_SHORT).show();
} }
dismissBottomSheet(); dismissBottomSheet();
}); });
}); });
@ -147,5 +139,4 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement
private void releaseMediaBrowser() { private void releaseMediaBrowser() {
MediaBrowser.releaseFuture(mediaBrowserListenableFuture); MediaBrowser.releaseFuture(mediaBrowserListenableFuture);
} }
} }

View file

@ -5,7 +5,6 @@ import android.content.ClipboardManager;
import android.content.ComponentName; import android.content.ComponentName;
import android.content.Context; import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -45,6 +44,8 @@ import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup; import com.google.android.material.chip.ChipGroup;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import android.content.Intent;
import androidx.media3.common.MediaItem;
import com.cappielloantonio.tempo.util.ExternalAudioWriter; import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import java.util.ArrayList; import java.util.ArrayList;
@ -66,9 +67,7 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
private AssetLinkUtil.AssetLink currentAlbumLink; private AssetLinkUtil.AssetLink currentAlbumLink;
private AssetLinkUtil.AssetLink currentArtistLink; private AssetLinkUtil.AssetLink currentArtistLink;
private boolean isFirstBatch = true;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture; private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private static final String TAG = "SongBottomSheetDialog";
@Nullable @Nullable
@Override @Override
@ -144,35 +143,21 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
TextView playRadio = view.findViewById(R.id.play_radio_text_view); TextView playRadio = view.findViewById(R.id.play_radio_text_view);
playRadio.setOnClickListener(v -> { playRadio.setOnClickListener(v -> {
MainActivity activity = (MainActivity) getActivity(); MediaManager.startQueue(mediaBrowserListenableFuture, song);
if (activity == null) return; ((MainActivity) requireActivity()).setBottomSheetInPeek(true);
ListenableFuture<MediaBrowser> activityBrowserFuture = activity.getMediaBrowserListenableFuture(); songBottomSheetViewModel.getInstantMix(getViewLifecycleOwner(), song).observe(getViewLifecycleOwner(), songs -> {
if (activityBrowserFuture == null) { MusicUtil.ratingFilter(songs);
Log.e(TAG, "MediaBrowser Future is null in MainActivity");
if (songs == null) {
dismissBottomSheet();
return; return;
} }
isFirstBatch = true; if (!songs.isEmpty()) {
Toast.makeText(requireContext(), R.string.bottom_sheet_generating_instant_mix, Toast.LENGTH_SHORT).show(); MediaManager.enqueue(mediaBrowserListenableFuture, songs, true);
songBottomSheetViewModel.getInstantMix(activity, song).observe(activity, media -> {
if (media == null || media.isEmpty()) return;
if (getActivity() == null) return;
MusicUtil.ratingFilter(media);
if (isFirstBatch) {
isFirstBatch = false;
MediaManager.startQueue(activityBrowserFuture, media, 0);
activity.setBottomSheetInPeek(true);
if (isAdded()) {
dismissBottomSheet(); dismissBottomSheet();
} }
} else {
MediaManager.enqueue(activityBrowserFuture, media, true);
}
}); });
}); });
@ -230,34 +215,6 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
updateDownloadButtons(); updateDownloadButtons();
String playlistId = requireArguments().getString(Constants.PLAYLIST_ID);
int itemPosition = requireArguments().getInt(Constants.ITEM_POSITION, -1);
TextView removeFromPlaylist = view.findViewById(R.id.remove_from_playlist_text_view);
if (playlistId != null && itemPosition != -1) {
removeFromPlaylist.setVisibility(View.VISIBLE);
removeFromPlaylist.setOnClickListener(v -> {
songBottomSheetViewModel.removeFromPlaylist(playlistId, itemPosition, new com.cappielloantonio.tempo.repository.PlaylistRepository.AddToPlaylistCallback() {
@Override
public void onSuccess() {
Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_remove_success, Toast.LENGTH_SHORT).show();
dismissBottomSheet();
}
@Override
public void onFailure() {
Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_remove_failure, Toast.LENGTH_SHORT).show();
dismissBottomSheet();
}
@Override
public void onAllSkipped() {
dismissBottomSheet();
}
});
});
}
TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view); TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view);
addToPlaylist.setOnClickListener(v -> { addToPlaylist.setOnClickListener(v -> {
Bundle bundle = new Bundle(); Bundle bundle = new Bundle();
@ -370,12 +327,16 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
chip.setVisibility(View.VISIBLE); chip.setVisibility(View.VISIBLE);
chip.setOnClickListener(v -> { chip.setOnClickListener(v -> {
if (assetLink != null) {
((MainActivity) requireActivity()).openAssetLink(assetLink); ((MainActivity) requireActivity()).openAssetLink(assetLink);
}
}); });
chip.setOnLongClickListener(v -> { chip.setOnLongClickListener(v -> {
if (assetLink != null) {
AssetLinkUtil.copyToClipboard(requireContext(), assetLink); AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, id), Toast.LENGTH_SHORT).show(); Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, id), Toast.LENGTH_SHORT).show();
}
return true; return true;
}); });
@ -436,5 +397,4 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
private void refreshShares() { private void refreshShares() {
homeViewModel.refreshShares(requireActivity()); homeViewModel.refreshShares(requireActivity());
} }
} }

View file

@ -1,95 +0,0 @@
package com.cappielloantonio.tempo.util
import android.content.Context
import android.security.KeyChain
import android.util.Log
import androidx.core.net.toUri
import okhttp3.internal.platform.Platform
import java.net.Socket
import java.security.KeyManagementException
import java.security.NoSuchAlgorithmException
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509KeyManager
object ClientCertManager {
private const val TAG = "ClientCertManager"
val trustManager = Platform.get().platformTrustManager()
var sslSocketFactory: SSLSocketFactory? = null
private set
@JvmStatic
fun setupSslSocketFactory(context: Context) {
sslSocketFactory = createSslSocketFactory(context)
sslSocketFactory?.let {
// HttpsURLConnection is used both by:
// - Glide: in IPv6StringLoader
// - ExoPlayer: in DefaultHttpDataSource
HttpsURLConnection.setDefaultSSLSocketFactory(it)
}
}
private fun createSslSocketFactory(context: Context): SSLSocketFactory? {
return try {
val clientKeyManager = object : X509KeyManager {
override fun getClientAliases(keyType: String?, issuers: Array<Principal>?) = null
override fun chooseClientAlias(
keyType: Array<String>?,
issuers: Array<Principal>?,
socket: Socket?
): String? {
val clientCert = Preferences.getClientCert() ?: return null
val server = Preferences.getServer() ?: return null
return if (server.toUri().host == socket?.inetAddress?.hostName) {
clientCert
} else null
}
override fun getServerAliases(keyType: String?, issuers: Array<Principal>?) = null
override fun chooseServerAlias(
keyType: String?,
issuers: Array<Principal>?,
socket: Socket?
) = null
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
val clientCert = Preferences.getClientCert()
return if (alias == clientCert && clientCert != null) {
KeyChain.getCertificateChain(
context,
clientCert
)
} else null
}
override fun getPrivateKey(alias: String?): PrivateKey? {
val clientCert = Preferences.getClientCert()
return if (alias == clientCert && clientCert != null) {
KeyChain.getPrivateKey(
context,
clientCert
)
} else null
}
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(arrayOf(clientKeyManager), arrayOf(trustManager), null)
sslContext.socketFactory
} catch (e: NoSuchAlgorithmException) {
Log.e(TAG, "Failed setting mTLS", e)
null
} catch (e: KeyManagementException) {
Log.e(TAG, "Failed setting mTLS", e)
null
}
}
}

View file

@ -11,7 +11,6 @@ object Constants {
const val ARTIST_OBJECT = "ARTIST_OBJECT" const val ARTIST_OBJECT = "ARTIST_OBJECT"
const val GENRE_OBJECT = "GENRE_OBJECT" const val GENRE_OBJECT = "GENRE_OBJECT"
const val PLAYLIST_OBJECT = "PLAYLIST_OBJECT" const val PLAYLIST_OBJECT = "PLAYLIST_OBJECT"
const val PLAYLIST_ID = "PLAYLIST_ID"
const val PODCAST_OBJECT = "PODCAST_OBJECT" const val PODCAST_OBJECT = "PODCAST_OBJECT"
const val PODCAST_CHANNEL_OBJECT = "PODCAST_CHANNEL_OBJECT" const val PODCAST_CHANNEL_OBJECT = "PODCAST_CHANNEL_OBJECT"
const val INTERNET_RADIO_STATION_OBJECT = "INTERNET_RADIO_STATION_OBJECT" const val INTERNET_RADIO_STATION_OBJECT = "INTERNET_RADIO_STATION_OBJECT"
@ -62,6 +61,13 @@ object Constants {
const val MEDIA_TYPE_VIDEO = "video" const val MEDIA_TYPE_VIDEO = "video"
const val MEDIA_TYPE_RADIO = "radio" const val MEDIA_TYPE_RADIO = "radio"
const val MEDIA_PLAYBACK_SPEED_080 = 0.8f
const val MEDIA_PLAYBACK_SPEED_100 = 1.0f
const val MEDIA_PLAYBACK_SPEED_125 = 1.25f
const val MEDIA_PLAYBACK_SPEED_150 = 1.50f
const val MEDIA_PLAYBACK_SPEED_175 = 1.75f
const val MEDIA_PLAYBACK_SPEED_200 = 2.0f
const val MEDIA_RECENTLY_PLAYED = "MEDIA_RECENTLY_PLAYED" const val MEDIA_RECENTLY_PLAYED = "MEDIA_RECENTLY_PLAYED"
const val MEDIA_MOST_PLAYED = "MEDIA_MOST_PLAYED" const val MEDIA_MOST_PLAYED = "MEDIA_MOST_PLAYED"
const val MEDIA_RECENTLY_ADDED = "MEDIA_RECENTLY_ADDED" const val MEDIA_RECENTLY_ADDED = "MEDIA_RECENTLY_ADDED"
@ -127,7 +133,4 @@ object Constants {
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF = "android.media3.session.demo.REPEAT_OFF" 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_ONE = "android.media3.session.demo.REPEAT_ONE"
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL = "android.media3.session.demo.REPEAT_ALL" const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL = "android.media3.session.demo.REPEAT_ALL"
enum class SeedType {
ARTIST, ALBUM, TRACK
}
} }

View file

@ -29,8 +29,6 @@ import java.net.CookieHandler;
import java.net.CookieManager; import java.net.CookieManager;
import java.net.CookiePolicy; import java.net.CookiePolicy;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
@UnstableApi @UnstableApi
@ -80,33 +78,12 @@ public final class DownloadUtil {
return httpDataSourceFactory; return httpDataSourceFactory;
} }
public static synchronized DataSource.Factory getHttpDataSourceFactoryForRadio() {
CookieManager cookieManager = new CookieManager();
cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ORIGINAL_SERVER);
CookieHandler.setDefault(cookieManager);
// Create a factory with ICY metadata support for radio streams
Map<String, String> defaultRequestProperties = new HashMap<>();
defaultRequestProperties.put("Icy-MetaData", "1");
defaultRequestProperties.put("User-Agent", "Tempus/1.0");
return new DefaultHttpDataSource
.Factory()
.setAllowCrossProtocolRedirects(true)
.setDefaultRequestProperties(defaultRequestProperties);
}
public static synchronized DataSource.Factory getUpstreamDataSourceFactory(Context context) { public static synchronized DataSource.Factory getUpstreamDataSourceFactory(Context context) {
DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory()); DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory());
dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
return dataSourceFactory; return dataSourceFactory;
} }
public static synchronized DataSource.Factory getUpstreamDataSourceFactoryForRadio(Context context) {
DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactoryForRadio());
return buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
}
public static synchronized DataSource.Factory getCacheDataSourceFactory(Context context) { public static synchronized DataSource.Factory getCacheDataSourceFactory(Context context) {
CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory() CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory()
.setCache(getStreamingCache(context)) .setCache(getStreamingCache(context))

View file

@ -20,15 +20,10 @@ class DynamicMediaSourceFactory(
) : MediaSource.Factory { ) : MediaSource.Factory {
override fun createMediaSource(mediaItem: MediaItem): MediaSource { override fun createMediaSource(mediaItem: MediaItem): MediaSource {
// Detect radio streams in a backwards-compatible way. val mediaType: String? = mediaItem.mediaMetadata.extras?.getString("type", "")
// Older Tempus versions tagged radio items via MediaMetadata extras
// (`type == MEDIA_TYPE_RADIO`), while newer upstream changes use an
// "ir-" mediaId prefix. Support BOTH so radio works after rebases.
val mediaType = mediaItem.mediaMetadata.extras?.getString("type", "")
val isRadio = mediaType == Constants.MEDIA_TYPE_RADIO || mediaItem.mediaId.startsWith("ir-")
val streamingCacheSize = Preferences.getStreamingCacheSize() val streamingCacheSize = Preferences.getStreamingCacheSize()
val bypassCache = isRadio val bypassCache = mediaType == Constants.MEDIA_TYPE_RADIO
val useUpstream = when { val useUpstream = when {
streamingCacheSize.toInt() == 0 -> true streamingCacheSize.toInt() == 0 -> true
@ -37,10 +32,7 @@ class DynamicMediaSourceFactory(
else -> true else -> true
} }
val dataSourceFactory: DataSource.Factory = if (bypassCache) { val dataSourceFactory: DataSource.Factory = if (useUpstream) {
// For radio streams, use a DataSourceFactory with ICY metadata support
DownloadUtil.getUpstreamDataSourceFactoryForRadio(context)
} else if (useUpstream) {
DownloadUtil.getUpstreamDataSourceFactory(context) DownloadUtil.getUpstreamDataSourceFactory(context)
} else { } else {
DownloadUtil.getCacheDataSourceFactory(context) DownloadUtil.getCacheDataSourceFactory(context)
@ -54,16 +46,8 @@ class DynamicMediaSourceFactory(
else -> { else -> {
val extractorsFactory: ExtractorsFactory = DefaultExtractorsFactory() val extractorsFactory: ExtractorsFactory = DefaultExtractorsFactory()
val progressiveFactory = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
.createMediaSource(mediaItem)
val uri = mediaItem.localConfiguration?.uri
val isTranscoding = uri?.getQueryParameter("format") != null && uri.getQueryParameter("format") != "raw"
if (isTranscoding && OpenSubsonicExtensionsUtil.isTranscodeOffsetExtensionAvailable()) {
TranscodingMediaSource(mediaItem, dataSourceFactory, progressiveFactory)
} else {
progressiveFactory.createMediaSource(mediaItem)
}
} }
} }
} }

View file

@ -1,10 +1,7 @@
package com.cappielloantonio.tempo.util; package com.cappielloantonio.tempo.util;
import android.content.ContentResolver;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log;
import android.util.Base64;
import androidx.annotation.OptIn; import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LifecycleOwner;
@ -17,7 +14,6 @@ import androidx.media3.common.HeartRating;
import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.glide.CustomGlideRequest; import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.provider.AlbumArtContentProvider;
import com.cappielloantonio.tempo.repository.DownloadRepository; import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation; import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
@ -26,7 +22,6 @@ import com.google.common.collect.ImmutableList;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.nio.charset.StandardCharsets;
@OptIn(markerClass = UnstableApi.class) @OptIn(markerClass = UnstableApi.class)
public class MappingUtil { public class MappingUtil {
@ -40,31 +35,21 @@ public class MappingUtil {
return mediaItems; return mediaItems;
} }
private static final String TAG = "MappingUtil";
public static MediaItem mapMediaItem(Child media) { public static MediaItem mapMediaItem(Child media) {
try {
Uri uri = getUri(media); Uri uri = getUri(media);
String coverArtId = media.getCoverArtId(); Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(media.getCoverArtId(), Preferences.getImageSize()));
Uri artworkUri = null;
if (coverArtId != null) {
artworkUri = AlbumArtContentProvider.contentUri(coverArtId);
}
Bundle bundle = new Bundle(); Bundle bundle = new Bundle();
bundle.putString("id", media.getId()); bundle.putString("id", media.getId());
bundle.putString("parentId", media.getParentId()); bundle.putString("parentId", media.getParentId());
bundle.putBoolean("isDir", media.isDir()); bundle.putBoolean("isDir", media.isDir());
bundle.putString("title", media.getTitle()); bundle.putString("title", media.getTitle());
bundle.putString("album", media.getAlbum()); bundle.putString("album", media.getAlbum());
bundle.putString("artist", media.getArtist()); bundle.putString("artist", media.getArtist());
bundle.putInt("track", media.getTrack() != null ? media.getTrack() : 0); bundle.putInt("track", media.getTrack() != null ? media.getTrack() : 0);
bundle.putInt("year", media.getYear() != null ? media.getYear() : 0); bundle.putInt("year", media.getYear() != null ? media.getYear() : 0);
bundle.putString("genre", media.getGenre()); bundle.putString("genre", media.getGenre());
bundle.putString("coverArtId", coverArtId); bundle.putString("coverArtId", media.getCoverArtId());
bundle.putLong("size", media.getSize() != null ? media.getSize() : 0); bundle.putLong("size", media.getSize() != null ? media.getSize() : 0);
bundle.putString("contentType", media.getContentType()); bundle.putString("contentType", media.getContentType());
bundle.putString("suffix", media.getSuffix()); bundle.putString("suffix", media.getSuffix());
@ -89,10 +74,9 @@ public class MappingUtil {
bundle.putInt("originalWidth", media.getOriginalWidth() != null ? media.getOriginalWidth() : 0); bundle.putInt("originalWidth", media.getOriginalWidth() != null ? media.getOriginalWidth() : 0);
bundle.putInt("originalHeight", media.getOriginalHeight() != null ? media.getOriginalHeight() : 0); bundle.putInt("originalHeight", media.getOriginalHeight() != null ? media.getOriginalHeight() : 0);
bundle.putString("uri", uri.toString()); bundle.putString("uri", uri.toString());
bundle.putString("assetLinkSong", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, media.getId()));
bundle.putString("assetLinkSong", media.getId() != null ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, media.getId()) : null); bundle.putString("assetLinkAlbum", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, media.getAlbumId()));
bundle.putString("assetLinkAlbum", media.getAlbumId() != null ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, media.getAlbumId()) : null); bundle.putString("assetLinkArtist", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, media.getArtistId()));
bundle.putString("assetLinkArtist", media.getArtistId() != null ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, media.getArtistId()) : null);
bundle.putString("assetLinkGenre", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_GENRE, media.getGenre())); bundle.putString("assetLinkGenre", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_GENRE, media.getGenre()));
Integer year = media.getYear(); Integer year = media.getYear();
bundle.putString("assetLinkYear", year != null && year != 0 ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(year)) : null); bundle.putString("assetLinkYear", year != null && year != 0 ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(year)) : null);
@ -129,17 +113,6 @@ public class MappingUtil {
.setMimeType(MimeTypes.BASE_TYPE_AUDIO) .setMimeType(MimeTypes.BASE_TYPE_AUDIO)
.setUri(uri) .setUri(uri)
.build(); .build();
} catch (Exception e) {
String id = media != null ? media.getId() : "NULL_MEDIA_OBJECT";
String title = media != null ? media.getTitle() : "N/A";
Log.e(TAG, "Instant Mix CRASH! Failed to map song to MediaItem. " +
"Problematic Song ID: " + id +
", Title: " + title +
". Inspect this song's Subsonic data for missing fields.", e);
throw new RuntimeException("Mapping failed for song ID: " + id, e);
}
} }
public static MediaItem mapMediaItem(MediaItem old) { public static MediaItem mapMediaItem(MediaItem old) {
@ -209,34 +182,18 @@ public class MappingUtil {
public static MediaItem mapInternetRadioStation(InternetRadioStation internetRadioStation) { public static MediaItem mapInternetRadioStation(InternetRadioStation internetRadioStation) {
Uri uri = Uri.parse(internetRadioStation.getStreamUrl()); Uri uri = Uri.parse(internetRadioStation.getStreamUrl());
Uri artworkUri = null;
String homePageUrl = internetRadioStation.getHomePageUrl();
String coverArtId = null;
if (homePageUrl != null && !homePageUrl.isEmpty() && MusicUtil.isImageUrl(homePageUrl)) {
String encodedUrl = Base64.encodeToString(homePageUrl.getBytes(StandardCharsets.UTF_8),
Base64.URL_SAFE | Base64.NO_WRAP);
coverArtId = "ir_" + encodedUrl;
artworkUri = AlbumArtContentProvider.contentUri(coverArtId);
}
Bundle bundle = new Bundle(); Bundle bundle = new Bundle();
bundle.putString("id", internetRadioStation.getId()); bundle.putString("id", internetRadioStation.getId());
bundle.putString("title", internetRadioStation.getName()); bundle.putString("title", internetRadioStation.getName());
bundle.putString("stationName", internetRadioStation.getName());
bundle.putString("uri", uri.toString()); bundle.putString("uri", uri.toString());
bundle.putString("type", Constants.MEDIA_TYPE_RADIO); bundle.putString("type", Constants.MEDIA_TYPE_RADIO);
bundle.putString("coverArtId", coverArtId);
if (homePageUrl != null) {
bundle.putString("homepageUrl", homePageUrl);
}
return new MediaItem.Builder() return new MediaItem.Builder()
.setMediaId(internetRadioStation.getId()) .setMediaId(internetRadioStation.getId())
.setMediaMetadata( .setMediaMetadata(
new MediaMetadata.Builder() new MediaMetadata.Builder()
.setTitle(internetRadioStation.getName()) .setTitle(internetRadioStation.getName())
.setArtworkUri(artworkUri)
.setExtras(bundle) .setExtras(bundle)
.setIsBrowsable(false) .setIsBrowsable(false)
.setIsPlayable(true) .setIsPlayable(true)
@ -255,7 +212,7 @@ public class MappingUtil {
public static MediaItem mapMediaItem(PodcastEpisode podcastEpisode) { public static MediaItem mapMediaItem(PodcastEpisode podcastEpisode) {
Uri uri = getUri(podcastEpisode); Uri uri = getUri(podcastEpisode);
Uri artworkUri = AlbumArtContentProvider.contentUri(podcastEpisode.getCoverArtId()); Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(podcastEpisode.getCoverArtId(), Preferences.getImageSize()));
Bundle bundle = new Bundle(); Bundle bundle = new Bundle();
bundle.putString("id", podcastEpisode.getId()); bundle.putString("id", podcastEpisode.getId());
@ -306,24 +263,13 @@ public class MappingUtil {
} }
private static Uri getUri(Child media) { private static Uri getUri(Child media) {
// Check if it's in our local SQL Database
DownloadRepository repo = new DownloadRepository();
Download localDownload = repo.getDownload(media.getId());
if (localDownload != null && localDownload.getDownloadUri() != null && !localDownload.getDownloadUri().isEmpty()) {
Log.d(TAG, "Playing local file for: " + media.getTitle());
return Uri.parse(localDownload.getDownloadUri());
}
// Legacy check for external directory, i think this was broken/buggy
if (Preferences.getDownloadDirectoryUri() != null) { if (Preferences.getDownloadDirectoryUri() != null) {
Uri local = ExternalAudioReader.getUri(media); Uri local = ExternalAudioReader.getUri(media);
if (local != null) return local; return local != null ? local : MusicUtil.getStreamUri(media.getId());
} }
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(media.getId())
// Fallback to streaming ? getDownloadUri(media.getId())
Log.d(TAG, "No local file found. Streaming: " + media.getTitle()); : MusicUtil.getStreamUri(media.getId());
return MusicUtil.getStreamUri(media.getId());
} }
private static Uri getUri(PodcastEpisode podcastEpisode) { private static Uri getUri(PodcastEpisode podcastEpisode) {

View file

@ -31,7 +31,7 @@ public class MusicUtil {
private static final Pattern BITRATE_PATTERN = Pattern.compile("&maxBitRate=\\d+"); private static final Pattern BITRATE_PATTERN = Pattern.compile("&maxBitRate=\\d+");
private static final Pattern FORMAT_PATTERN = Pattern.compile("&format=\\w+"); private static final Pattern FORMAT_PATTERN = Pattern.compile("&format=\\w+");
public static Uri getStreamUri(String id, int timeOffset) { public static Uri getStreamUri(String id) {
Map<String, String> params = App.getSubsonicClientInstance(false).getParams(); Map<String, String> params = App.getSubsonicClientInstance(false).getParams();
StringBuilder uri = new StringBuilder(); StringBuilder uri = new StringBuilder();
@ -52,18 +52,12 @@ public class MusicUtil {
if (params.containsKey("c") && params.get("c") != null) if (params.containsKey("c") && params.get("c") != null)
uri.append("&c=").append(params.get("c")); uri.append("&c=").append(params.get("c"));
String selectedBitrate = getBitratePreference();
String selectedFormat = getTranscodingFormatPreference();
Log.i(TAG, "DEBUG: Requesting Format: " + selectedFormat + " at Bitrate: " + selectedBitrate);
if (!Preferences.isServerPrioritized()) if (!Preferences.isServerPrioritized())
uri.append("&maxBitRate=").append(getBitratePreference()); uri.append("&maxBitRate=").append(getBitratePreference());
if (!Preferences.isServerPrioritized()) if (!Preferences.isServerPrioritized())
uri.append("&format=").append(getTranscodingFormatPreference()); uri.append("&format=").append(getTranscodingFormatPreference());
if (Preferences.askForEstimateContentLength()) if (Preferences.askForEstimateContentLength())
uri.append("&estimateContentLength=true"); uri.append("&estimateContentLength=true");
if (timeOffset > 0)
uri.append("&timeOffset=").append(timeOffset);
uri.append("&id=").append(id); uri.append("&id=").append(id);
@ -72,22 +66,8 @@ public class MusicUtil {
return Uri.parse(uri.toString()); return Uri.parse(uri.toString());
} }
public static Uri getStreamUri(String id) {
return getStreamUri(id, 0);
}
public static Uri updateStreamUri(Uri uri) { public static Uri updateStreamUri(Uri uri) {
if (uri == null) return null;
String scheme = uri.getScheme();
// If it is local (content:// or file://), return it IMMEDIATELY.
// This prevents the code below from appending &maxBitRate to a local path.
if (scheme != null && (scheme.equals("content") || scheme.equals("file"))) {
return uri;
}
String s = uri.toString(); String s = uri.toString();
Matcher m1 = BITRATE_PATTERN.matcher(s); Matcher m1 = BITRATE_PATTERN.matcher(s);
s = m1.replaceAll(""); s = m1.replaceAll("");
Matcher m2 = FORMAT_PATTERN.matcher(s); Matcher m2 = FORMAT_PATTERN.matcher(s);
@ -171,6 +151,7 @@ public class MusicUtil {
return Uri.parse(uri.toString()); return Uri.parse(uri.toString());
} }
public static String getReadableDurationString(Long duration, boolean millis) { public static String getReadableDurationString(Long duration, boolean millis) {
long lenght = duration != null ? duration : 0; long lenght = duration != null ? duration : 0;
@ -316,17 +297,13 @@ public class MusicUtil {
if (network == null || networkCapabilities == null) return "raw"; if (network == null || networkCapabilities == null) return "raw";
String format;
if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
format = Preferences.getAudioTranscodeFormatWifi(); return Preferences.getAudioTranscodeFormatWifi();
Log.d(TAG, "DEBUG: Using WIFI Format: " + format);
} else if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { } else if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
format = Preferences.getAudioTranscodeFormatMobile(); return Preferences.getAudioTranscodeFormatMobile();
Log.d(TAG, "DEBUG: Using MOBILE Format: " + format);
} else { } else {
format = Preferences.getAudioTranscodeFormatWifi(); return Preferences.getAudioTranscodeFormatWifi();
} }
return format;
} }
public static String getBitratePreferenceForDownload() { public static String getBitratePreferenceForDownload() {
@ -377,15 +354,4 @@ public class MusicUtil {
toFilter.addAll(filtered); toFilter.addAll(filtered);
} }
public static boolean isImageUrl(String url) {
if (url == null || url.isEmpty())
return false;
String path = url.toLowerCase().trim().split("\\?")[0];
return path.endsWith(".jpg") || path.endsWith(".jpeg") ||
path.endsWith(".png") || path.endsWith(".webp") ||
path.endsWith(".gif") || path.endsWith(".bmp") ||
path.endsWith(".svg");
}
} }

View file

@ -16,7 +16,6 @@ object Preferences {
private const val TOKEN = "token" private const val TOKEN = "token"
private const val SALT = "salt" private const val SALT = "salt"
private const val LOW_SECURITY = "low_security" private const val LOW_SECURITY = "low_security"
private const val CLIENT_CERT = "client_cert"
private const val BATTERY_OPTIMIZATION = "battery_optimization" private const val BATTERY_OPTIMIZATION = "battery_optimization"
private const val SERVER_ID = "server_id" private const val SERVER_ID = "server_id"
private const val OPEN_SUBSONIC = "open_subsonic" private const val OPEN_SUBSONIC = "open_subsonic"
@ -25,15 +24,11 @@ object Preferences {
private const val IN_USE_SERVER_ADDRESS = "in_use_server_address" private const val IN_USE_SERVER_ADDRESS = "in_use_server_address"
private const val NEXT_SERVER_SWITCH = "next_server_switch" private const val NEXT_SERVER_SWITCH = "next_server_switch"
private const val PLAYBACK_SPEED = "playback_speed" private const val PLAYBACK_SPEED = "playback_speed"
private const val BITRATE_VISIBLE = "bitrate_visible"
private const val SKIP_SILENCE = "skip_silence" private const val SKIP_SILENCE = "skip_silence"
private const val SHUFFLE_MODE = "shuffle_mode" private const val SHUFFLE_MODE = "shuffle_mode"
private const val REPEAT_MODE = "repeat_mode" private const val REPEAT_MODE = "repeat_mode"
private const val IMAGE_CACHE_SIZE = "image_cache_size" private const val IMAGE_CACHE_SIZE = "image_cache_size"
private const val STREAMING_CACHE_SIZE = "streaming_cache_size" private const val STREAMING_CACHE_SIZE = "streaming_cache_size"
private const val LANDSCAPE_ITEMS_PER_ROW = "landscape_items_per_row"
private const val ENABLE_DRAWER_ON_PORTRAIT = "enable_drawer_on_portrait"
private const val HIDE_BOTTOM_NAVBAR_ON_PORTRAIT = "hide_bottom_navbar_on_portrait"
private const val IMAGE_SIZE = "image_size" private const val IMAGE_SIZE = "image_size"
private const val MAX_BITRATE_WIFI = "max_bitrate_wifi" private const val MAX_BITRATE_WIFI = "max_bitrate_wifi"
private const val MAX_BITRATE_MOBILE = "max_bitrate_mobile" private const val MAX_BITRATE_MOBILE = "max_bitrate_mobile"
@ -79,8 +74,6 @@ object Preferences {
private const val CONTINUOUS_PLAY = "continuous_play" private const val CONTINUOUS_PLAY = "continuous_play"
private const val LAST_INSTANT_MIX = "last_instant_mix" private const val LAST_INSTANT_MIX = "last_instant_mix"
private const val ALLOW_PLAYLIST_DUPLICATES = "allow_playlist_duplicates" private const val ALLOW_PLAYLIST_DUPLICATES = "allow_playlist_duplicates"
private const val HOME_SORT_PLAYLISTS = "home_sort_playlists"
private const val DEFAULT_HOME_SORT_PLAYLISTS_SORT_ORDER = Constants.PLAYLIST_ORDER_BY_RANDOM
private const val EQUALIZER_ENABLED = "equalizer_enabled" private const val EQUALIZER_ENABLED = "equalizer_enabled"
private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels" private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels"
private const val MINI_SHUFFLE_BUTTON_VISIBILITY = "mini_shuffle_button_visibility" private const val MINI_SHUFFLE_BUTTON_VISIBILITY = "mini_shuffle_button_visibility"
@ -88,19 +81,6 @@ object Preferences {
private const val ALBUM_SORT_ORDER = "album_sort_order" private const val ALBUM_SORT_ORDER = "album_sort_order"
private const val DEFAULT_ALBUM_SORT_ORDER = Constants.ALBUM_ORDER_BY_NAME private const val DEFAULT_ALBUM_SORT_ORDER = Constants.ALBUM_ORDER_BY_NAME
private const val ARTIST_SORT_BY_ALBUM_COUNT= "artist_sort_by_album_count" private const val ARTIST_SORT_BY_ALBUM_COUNT= "artist_sort_by_album_count"
private const val SORT_SEARCH_CHRONOLOGICALLY= "sort_search_chronologically"
private const val ARTIST_DISPLAY_BIOGRAPHY= "artist_display_biography"
private const val NETWORK_PING_TIMEOUT = "network_ping_timeout_base"
private const val AA_ALBUM_VIEW = "androidauto_album_view"
private const val AA_HOME_VIEW = "androidauto_home_view"
private const val AA_PLAYLIST_VIEW = "androidauto_playlist_view"
private const val AA_PODCAST_VIEW = "androidauto_podcast_view"
private const val AA_RADIO_VIEW = "androidauto_radio_view"
private const val AA_FIRST_TAB = "androidauto_first_tab"
private const val AA_SECOND_TAB = "androidauto_second_tab"
private const val AA_THIRD_TAB = "androidauto_third_tab"
private const val AA_FOURTH_TAB = "androidauto_fourth_tab"
@JvmStatic @JvmStatic
fun getServer(): String? { fun getServer(): String? {
@ -112,19 +92,6 @@ object Preferences {
App.getInstance().preferences.edit().putString(SERVER, server).apply() App.getInstance().preferences.edit().putString(SERVER, server).apply()
} }
@JvmStatic
fun getNetworkPingTimeout(): Int {
val timeoutString = App.getInstance().preferences.getString(NETWORK_PING_TIMEOUT, "2") ?: "2"
return (timeoutString.toIntOrNull() ?: 2).coerceAtLeast(1)
}
@JvmStatic
fun setNetworkPingTimeout(pingTimeout: String?) {
App.getInstance().preferences.edit().putString(NETWORK_PING_TIMEOUT, pingTimeout).apply()
}
@JvmStatic @JvmStatic
fun getUser(): String? { fun getUser(): String? {
return App.getInstance().preferences.getString(USER, null) return App.getInstance().preferences.getString(USER, null)
@ -175,16 +142,6 @@ object Preferences {
App.getInstance().preferences.edit().putBoolean(LOW_SECURITY, isLowSecurity).apply() App.getInstance().preferences.edit().putBoolean(LOW_SECURITY, isLowSecurity).apply()
} }
@JvmStatic
fun getClientCert(): String? {
return App.getInstance().preferences.getString(CLIENT_CERT, null)
}
@JvmStatic
fun setClientCert(clientCert: String?) {
App.getInstance().preferences.edit().putString(CLIENT_CERT, clientCert).apply()
}
@JvmStatic @JvmStatic
fun getServerId(): String? { fun getServerId(): String? {
return App.getInstance().preferences.getString(SERVER_ID, null) return App.getInstance().preferences.getString(SERVER_ID, null)
@ -293,16 +250,6 @@ object Preferences {
App.getInstance().preferences.edit().putFloat(PLAYBACK_SPEED, playbackSpeed).apply() App.getInstance().preferences.edit().putFloat(PLAYBACK_SPEED, playbackSpeed).apply()
} }
@JvmStatic
fun getBitrateVisible(): Boolean {
return App.getInstance().preferences.getBoolean(BITRATE_VISIBLE, true)
}
@JvmStatic
fun setBitrateVisible(bitrateVisible: Boolean) {
App.getInstance().preferences.edit().putBoolean(BITRATE_VISIBLE, bitrateVisible).apply()
}
@JvmStatic @JvmStatic
fun isSkipSilenceMode(): Boolean { fun isSkipSilenceMode(): Boolean {
return App.getInstance().preferences.getBoolean(SKIP_SILENCE, false) return App.getInstance().preferences.getBoolean(SKIP_SILENCE, false)
@ -338,21 +285,6 @@ object Preferences {
return App.getInstance().preferences.getString(IMAGE_CACHE_SIZE, "500")!!.toInt() return App.getInstance().preferences.getString(IMAGE_CACHE_SIZE, "500")!!.toInt()
} }
@JvmStatic
fun getLandscapeItemsPerRow(): Int {
return App.getInstance().preferences.getString(LANDSCAPE_ITEMS_PER_ROW, "4")!!.toInt()
}
@JvmStatic
fun getEnableDrawerOnPortrait(): Boolean {
return App.getInstance().preferences.getBoolean(ENABLE_DRAWER_ON_PORTRAIT, false)
}
@JvmStatic
fun getHideBottomNavbarOnPortrait(): Boolean {
return App.getInstance().preferences.getBoolean(HIDE_BOTTOM_NAVBAR_ON_PORTRAIT, false)
}
@JvmStatic @JvmStatic
fun getImageSize(): Int { fun getImageSize(): Int {
return App.getInstance().preferences.getString(IMAGE_SIZE, "-1")!!.toInt() return App.getInstance().preferences.getString(IMAGE_SIZE, "-1")!!.toInt()
@ -691,16 +623,6 @@ object Preferences {
return App.getInstance().preferences.getBoolean(ALLOW_PLAYLIST_DUPLICATES, false) return App.getInstance().preferences.getBoolean(ALLOW_PLAYLIST_DUPLICATES, false)
} }
@JvmStatic
fun getHomeSortPlaylists(): String {
return App.getInstance().preferences.getString(HOME_SORT_PLAYLISTS, DEFAULT_HOME_SORT_PLAYLISTS_SORT_ORDER) ?: DEFAULT_HOME_SORT_PLAYLISTS_SORT_ORDER
}
@JvmStatic
fun getHomeSortPlaylists(sortOrder: String) {
App.getInstance().preferences.edit().putString(HOME_SORT_PLAYLISTS, sortOrder).apply()
}
@JvmStatic @JvmStatic
fun setEqualizerEnabled(enabled: Boolean) { fun setEqualizerEnabled(enabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(EQUALIZER_ENABLED, enabled).apply() App.getInstance().preferences.edit().putBoolean(EQUALIZER_ENABLED, enabled).apply()
@ -752,65 +674,4 @@ object Preferences {
else else
return Constants.ARTIST_ORDER_BY_NAME return Constants.ARTIST_ORDER_BY_NAME
} }
@JvmStatic
fun isSearchSortingChronologicallyEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(SORT_SEARCH_CHRONOLOGICALLY, false)
}
@JvmStatic
fun getArtistDisplayBiography(): Boolean {
return App.getInstance().preferences.getBoolean(ARTIST_DISPLAY_BIOGRAPHY, true)
}
@JvmStatic
fun setArtistDisplayBiography(displayBiographyEnabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(ARTIST_DISPLAY_BIOGRAPHY, displayBiographyEnabled).apply()
}
@JvmStatic
fun isAndroidAutoAlbumViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_ALBUM_VIEW, true)
}
@JvmStatic
fun isAndroidAutoHomeViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_HOME_VIEW, false)
}
@JvmStatic
fun isAndroidAutoPlaylistViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_PLAYLIST_VIEW, false)
}
@JvmStatic
fun isAndroidAutoPodcastViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_PODCAST_VIEW, false)
}
@JvmStatic
fun isAndroidAutoRadioViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_RADIO_VIEW, false)
}
@JvmStatic
fun getAndroidAutoFirstTab(): Int {
return App.getInstance().preferences.getString(AA_FIRST_TAB, "0")!!.toInt()
}
@JvmStatic
fun getAndroidAutoSecondTab(): Int {
return App.getInstance().preferences.getString(AA_SECOND_TAB, "1")!!.toInt()
}
@JvmStatic
fun getAndroidAutoThirdTab(): Int {
return App.getInstance().preferences.getString(AA_THIRD_TAB, "2")!!.toInt()
}
@JvmStatic
fun getAndroidAutoFourthTab(): Int {
return App.getInstance().preferences.getString(AA_FOURTH_TAB, "3")!!.toInt()
}
} }

View file

@ -1,13 +1,11 @@
package com.cappielloantonio.tempo.util; package com.cappielloantonio.tempo.util;
import androidx.annotation.OptIn; import androidx.annotation.OptIn;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
import androidx.media3.common.Metadata; import androidx.media3.common.Metadata;
import androidx.media3.common.Tracks; import androidx.media3.common.Tracks;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.Player; import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.extractor.metadata.id3.InternalFrame;
import com.cappielloantonio.tempo.model.ReplayGain; import com.cappielloantonio.tempo.model.ReplayGain;
@ -19,7 +17,7 @@ import java.util.Objects;
public class ReplayGainUtil { public class ReplayGainUtil {
private static final String[] tags = {"REPLAYGAIN_TRACK_GAIN", "REPLAYGAIN_ALBUM_GAIN", "R128_TRACK_GAIN", "R128_ALBUM_GAIN"}; private static final String[] tags = {"REPLAYGAIN_TRACK_GAIN", "REPLAYGAIN_ALBUM_GAIN", "R128_TRACK_GAIN", "R128_ALBUM_GAIN"};
public static void setReplayGain(Player player, Tracks tracks) { public static void setReplayGain(ExoPlayer player, Tracks tracks) {
List<Metadata> metadata = getMetadata(tracks); List<Metadata> metadata = getMetadata(tracks);
List<ReplayGain> gains = getReplayGains(metadata); List<ReplayGain> gains = getReplayGains(metadata);
@ -64,7 +62,7 @@ public class ReplayGainUtil {
} }
} }
if (gains.isEmpty()) gains.add(0, new ReplayGain()); if (gains.size() == 0) gains.add(0, new ReplayGain());
if (gains.size() == 1) gains.add(1, new ReplayGain()); if (gains.size() == 1) gains.add(1, new ReplayGain());
return gains; return gains;
@ -83,32 +81,26 @@ public class ReplayGainUtil {
private static ReplayGain setReplayGains(Metadata.Entry entry) { private static ReplayGain setReplayGains(Metadata.Entry entry) {
ReplayGain replayGain = new ReplayGain(); ReplayGain replayGain = new ReplayGain();
// The logic below assumes .toString() contains the dB value. That's not the case for InternalFrame if (entry.toString().contains(tags[0])) {
String str = entry.toString(); replayGain.setTrackGain(parseReplayGainTag(entry));
if (entry instanceof InternalFrame) {
str = ((InternalFrame) entry).description + ((InternalFrame) entry).text;
} }
if (str.contains(tags[0])) { if (entry.toString().contains(tags[1])) {
replayGain.setTrackGain(parseReplayGainTag(str)); replayGain.setAlbumGain(parseReplayGainTag(entry));
} }
if (str.contains(tags[1])) { if (entry.toString().contains(tags[2])) {
replayGain.setAlbumGain(parseReplayGainTag(str)); replayGain.setTrackGain(parseReplayGainTag(entry) / 256f);
} }
if (str.contains(tags[2])) { if (entry.toString().contains(tags[3])) {
replayGain.setTrackGain(parseReplayGainTag(str) / 256f); replayGain.setAlbumGain(parseReplayGainTag(entry) / 256f);
}
if (str.contains(tags[3])) {
replayGain.setAlbumGain(parseReplayGainTag(str) / 256f);
} }
return replayGain; return replayGain;
} }
private static Float parseReplayGainTag(String entry) { private static Float parseReplayGainTag(Metadata.Entry entry) {
try { try {
return Float.parseFloat(entry.toString().replaceAll("[^\\d.-]", "")); return Float.parseFloat(entry.toString().replaceAll("[^\\d.-]", ""));
} catch (NumberFormatException exception) { } catch (NumberFormatException exception) {
@ -116,7 +108,7 @@ public class ReplayGainUtil {
} }
} }
private static void applyReplayGain(Player player, List<ReplayGain> gains) { private static void applyReplayGain(ExoPlayer player, List<ReplayGain> gains) {
if (Objects.equals(Preferences.getReplayGainMode(), "disabled") || gains == null || gains.isEmpty()) { if (Objects.equals(Preferences.getReplayGainMode(), "disabled") || gains == null || gains.isEmpty()) {
setNoReplayGain(player); setNoReplayGain(player);
return; return;
@ -145,33 +137,33 @@ public class ReplayGainUtil {
setNoReplayGain(player); setNoReplayGain(player);
} }
private static void setNoReplayGain(Player player) { private static void setNoReplayGain(ExoPlayer player) {
setReplayGain(player, 0f); setReplayGain(player, 0f);
} }
private static void setTrackReplayGain(Player player, List<ReplayGain> gains) { private static void setTrackReplayGain(ExoPlayer player, List<ReplayGain> gains) {
float trackGain = gains.get(0).getTrackGain() != 0f ? gains.get(0).getTrackGain() : gains.get(1).getTrackGain(); float trackGain = gains.get(0).getTrackGain() != 0f ? gains.get(0).getTrackGain() : gains.get(1).getTrackGain();
setReplayGain(player, trackGain != 0f ? trackGain : 0f); setReplayGain(player, trackGain != 0f ? trackGain : 0f);
} }
private static void setAlbumReplayGain(Player player, List<ReplayGain> gains) { private static void setAlbumReplayGain(ExoPlayer player, List<ReplayGain> gains) {
float albumGain = gains.get(0).getAlbumGain() != 0f ? gains.get(0).getAlbumGain() : gains.get(1).getAlbumGain(); float albumGain = gains.get(0).getAlbumGain() != 0f ? gains.get(0).getAlbumGain() : gains.get(1).getAlbumGain();
setReplayGain(player, albumGain != 0f ? albumGain : 0f); setReplayGain(player, albumGain != 0f ? albumGain : 0f);
} }
private static void setAutoReplayGain(Player player, List<ReplayGain> gains) { private static void setAutoReplayGain(ExoPlayer player, List<ReplayGain> gains) {
float albumGain = gains.get(0).getAlbumGain() != 0f ? gains.get(0).getAlbumGain() : gains.get(1).getAlbumGain(); float albumGain = gains.get(0).getAlbumGain() != 0f ? gains.get(0).getAlbumGain() : gains.get(1).getAlbumGain();
float trackGain = gains.get(0).getTrackGain() != 0f ? gains.get(0).getTrackGain() : gains.get(1).getTrackGain(); float trackGain = gains.get(0).getTrackGain() != 0f ? gains.get(0).getTrackGain() : gains.get(1).getTrackGain();
setReplayGain(player, albumGain != 0f ? albumGain : trackGain); setReplayGain(player, albumGain != 0f ? albumGain : trackGain);
} }
private static boolean areTracksConsecutive(Player player) { private static boolean areTracksConsecutive(ExoPlayer player) {
MediaItem currentMediaItem = player.getCurrentMediaItem(); MediaItem currentMediaItem = player.getCurrentMediaItem();
int prevMediaItemIndex = player.getPreviousMediaItemIndex(); int currentMediaItemIndex = player.getCurrentMediaItemIndex();
MediaItem pastMediaItem = prevMediaItemIndex == C.INDEX_UNSET ? null : player.getMediaItemAt(prevMediaItemIndex); MediaItem pastMediaItem = currentMediaItemIndex > 0 ? player.getMediaItemAt(currentMediaItemIndex - 1) : null;
return currentMediaItem != null && return currentMediaItem != null &&
pastMediaItem != null && pastMediaItem != null &&
@ -180,7 +172,7 @@ public class ReplayGainUtil {
pastMediaItem.mediaMetadata.albumTitle.toString().equals(currentMediaItem.mediaMetadata.albumTitle.toString()); pastMediaItem.mediaMetadata.albumTitle.toString().equals(currentMediaItem.mediaMetadata.albumTitle.toString());
} }
private static void setReplayGain(Player player, float gain) { private static void setReplayGain(ExoPlayer player, float gain) {
player.setVolume((float) Math.pow(10f, gain / 20f)); player.setVolume((float) Math.pow(10f, gain / 20f));
} }
} }

View file

@ -1,328 +0,0 @@
package com.cappielloantonio.tempo.util
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Timeline
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.TransferListener
import androidx.media3.decoder.DecoderInputBuffer
import androidx.media3.exoplayer.FormatHolder
import androidx.media3.exoplayer.LoadingInfo
import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.source.CompositeMediaSource
import androidx.media3.exoplayer.source.ForwardingTimeline
import androidx.media3.exoplayer.source.MediaPeriod
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.source.SampleStream
import androidx.media3.exoplayer.trackselection.ExoTrackSelection
import androidx.media3.exoplayer.upstream.Allocator
@OptIn(UnstableApi::class)
class TranscodingMediaSource(
private val mediaItem: MediaItem,
private val dataSourceFactory: DataSource.Factory,
private val progressiveMediaSourceFactory: ProgressiveMediaSource.Factory
) : CompositeMediaSource<Void>() {
private var durationUs: Long = C.TIME_UNSET
private var currentSource: MediaSource? = null
init {
val extras = mediaItem.mediaMetadata.extras
val uri = mediaItem.localConfiguration?.uri
val isLocal = uri?.scheme == "content" || uri?.scheme == "file"
// Only apply the override if it's NOT a local file
if (!isLocal && extras != null && extras.containsKey("duration")) {
val seconds = extras.getInt("duration")
if (seconds > 0) {
durationUs = Util.msToUs(seconds * 1000L)
}
}
currentSource = progressiveMediaSourceFactory.createMediaSource(mediaItem)
}
override fun getMediaItem() = mediaItem
override fun prepareSourceInternal(mediaTransferListener: TransferListener?) {
super.prepareSourceInternal(mediaTransferListener)
val initialSource = progressiveMediaSourceFactory.createMediaSource(mediaItem)
currentSource = initialSource
prepareChildSource(null, initialSource)
}
override fun onChildSourceInfoRefreshed(
childSourceId: Void?,
mediaSource: MediaSource,
newTimeline: Timeline
) {
val timeline =
if (durationUs != C.TIME_UNSET) {
DurationOverridingTimeline(newTimeline, durationUs)
} else {
newTimeline
}
refreshSourceInfo(timeline)
}
override fun createPeriod(
id: MediaSource.MediaPeriodId,
allocator: Allocator,
startPositionUs: Long
): MediaPeriod {
val source = currentSource ?: throw IllegalStateException("Source not ready")
val childPeriod = source.createPeriod(id, allocator, startPositionUs)
return TranscodingMediaPeriod(childPeriod, source, id, allocator)
}
override fun releasePeriod(mediaPeriod: MediaPeriod) {
val transcodingPeriod = mediaPeriod as TranscodingMediaPeriod
transcodingPeriod.release()
if (transcodingPeriod.currentOffsetUs > 0) {
releaseChildSource(null)
val initialSource = progressiveMediaSourceFactory.createMediaSource(mediaItem)
currentSource = initialSource
prepareChildSource(null, initialSource)
}
}
override fun getMediaPeriodIdForChildMediaPeriodId(
childSourceId: Void?,
mediaPeriodId: MediaSource.MediaPeriodId
) = mediaPeriodId
private inner class TranscodingMediaPeriod(
private var currentPeriod: MediaPeriod,
private var source: MediaSource,
private val id: MediaSource.MediaPeriodId,
private val allocator: Allocator
) : MediaPeriod, MediaPeriod.Callback {
private var localCallback: MediaPeriod.Callback? = null
internal var currentOffsetUs: Long = 0
private var isReloading = false
private var lastSelections: Array<out ExoTrackSelection?>? = null
private var lastMayRetainStreamFlags: BooleanArray? = null
private var activeWrappers: Array<OffsetSampleStream?> = emptyArray()
fun release() {
source.releasePeriod(currentPeriod)
}
override fun prepare(callback: MediaPeriod.Callback, positionUs: Long) {
localCallback = callback
currentPeriod.prepare(this, positionUs)
}
override fun maybeThrowPrepareError() {
if (!isReloading) currentPeriod.maybeThrowPrepareError()
}
override fun getTrackGroups() = currentPeriod.trackGroups
override fun getStreamKeys(trackSelections: MutableList<ExoTrackSelection>) =
currentPeriod.getStreamKeys(trackSelections)
override fun selectTracks(
selections: Array<out ExoTrackSelection?>,
mayRetainStreamFlags: BooleanArray,
streams: Array<SampleStream?>,
streamResetFlags: BooleanArray,
positionUs: Long
): Long {
lastSelections = selections
lastMayRetainStreamFlags = mayRetainStreamFlags
val childStreams = arrayOfNulls<SampleStream>(streams.size)
streams.forEachIndexed { i, stream ->
childStreams[i] = (stream as? OffsetSampleStream)?.childStream
}
val startPos =
currentPeriod.selectTracks(
selections,
mayRetainStreamFlags,
childStreams,
streamResetFlags,
positionUs - currentOffsetUs
)
val newWrappers = arrayOfNulls<OffsetSampleStream>(streams.size)
for (i in streams.indices) {
val child = childStreams[i]
if (child == null) {
streams[i] = null
} else {
val existingWrapper = streams[i] as? OffsetSampleStream
if (existingWrapper != null && existingWrapper.childStream === child) {
newWrappers[i] = existingWrapper
} else {
val wrapper = OffsetSampleStream(child)
newWrappers[i] = wrapper
streams[i] = wrapper
}
}
}
activeWrappers = newWrappers
return startPos + currentOffsetUs
}
override fun discardBuffer(positionUs: Long, toKeyframe: Boolean) {
if (!isReloading) {
currentPeriod.discardBuffer(positionUs - currentOffsetUs, toKeyframe)
}
}
override fun readDiscontinuity(): Long {
if (isReloading) return C.TIME_UNSET
val discontinuity = currentPeriod.readDiscontinuity()
return if (discontinuity == C.TIME_UNSET) C.TIME_UNSET
else discontinuity + currentOffsetUs
}
override fun seekToUs(positionUs: Long): Long {
if (positionUs == 0L && currentOffsetUs == 0L) {
return currentPeriod.seekToUs(positionUs)
}
reloadSource(positionUs)
return positionUs
}
override fun getAdjustedSeekPositionUs(positionUs: Long, seekParameters: SeekParameters) =
positionUs
override fun getBufferedPositionUs(): Long {
if (isReloading) return currentOffsetUs
val buffered = currentPeriod.bufferedPositionUs
if (buffered == C.TIME_END_OF_SOURCE) return C.TIME_END_OF_SOURCE
return if (buffered == C.TIME_UNSET) C.TIME_UNSET else buffered + currentOffsetUs
}
override fun getNextLoadPositionUs(): Long {
if (isReloading) return C.TIME_UNSET
val next = currentPeriod.nextLoadPositionUs
if (next == C.TIME_END_OF_SOURCE) return C.TIME_END_OF_SOURCE
return if (next == C.TIME_UNSET) C.TIME_UNSET else next + currentOffsetUs
}
override fun reevaluateBuffer(positionUs: Long) {
if (!isReloading) currentPeriod.reevaluateBuffer(positionUs - currentOffsetUs)
}
override fun continueLoading(isLoading: LoadingInfo): Boolean {
if (isReloading) return false
val builder = isLoading.buildUpon()
builder.setPlaybackPositionUs(isLoading.playbackPositionUs - currentOffsetUs)
return currentPeriod.continueLoading(builder.build())
}
override fun isLoading() = isReloading || currentPeriod.isLoading
override fun onPrepared(mediaPeriod: MediaPeriod) {
if (isReloading && mediaPeriod == currentPeriod) {
isReloading = false
restoreTracks()
localCallback?.onContinueLoadingRequested(this)
} else {
localCallback?.onPrepared(this)
}
}
override fun onContinueLoadingRequested(source: MediaPeriod) {
if (!isReloading) localCallback?.onContinueLoadingRequested(this)
}
private fun reloadSource(positionUs: Long) {
isReloading = true
currentOffsetUs = positionUs
activeWrappers.forEach { it?.childStream = null }
source.releasePeriod(currentPeriod)
releaseChildSource(null)
val seconds = Util.usToMs(positionUs) / 1000
val newUri = MusicUtil.getStreamUri(mediaItem.mediaId, seconds.toInt())
val newMediaItem = mediaItem.buildUpon().setUri(newUri).build()
val newSource = progressiveMediaSourceFactory.createMediaSource(newMediaItem)
source = newSource
currentSource = newSource
prepareChildSource(null, newSource)
val newPeriod = newSource.createPeriod(id, allocator, 0)
currentPeriod = newPeriod
newPeriod.prepare(this, 0)
}
private fun restoreTracks() {
val selections = lastSelections ?: return
val flags = lastMayRetainStreamFlags ?: return
val childStreams = arrayOfNulls<SampleStream>(activeWrappers.size)
val streamResetFlags = BooleanArray(activeWrappers.size)
currentPeriod.selectTracks(selections, flags, childStreams, streamResetFlags, 0)
for (i in activeWrappers.indices) {
activeWrappers[i]?.childStream = childStreams[i]
}
}
private inner class OffsetSampleStream(var childStream: SampleStream?) : SampleStream {
override fun isReady() = childStream?.isReady ?: false
override fun maybeThrowError() {
childStream?.maybeThrowError()
}
override fun readData(
formatHolder: FormatHolder,
buffer: DecoderInputBuffer,
readFlags: Int
): Int {
val stream = childStream ?: return C.RESULT_NOTHING_READ
val result = stream.readData(formatHolder, buffer, readFlags)
if (result == C.RESULT_BUFFER_READ && !buffer.isEndOfStream) {
buffer.timeUs += currentOffsetUs
}
return result
}
override fun skipData(positionUs: Long) =
childStream?.skipData(positionUs - currentOffsetUs) ?: 0
}
}
private class DurationOverridingTimeline(timeline: Timeline, private val durationUs: Long) :
ForwardingTimeline(timeline) {
override fun getWindow(
windowIndex: Int,
window: Window,
defaultPositionProjectionUs: Long
): Window {
super.getWindow(windowIndex, window, defaultPositionProjectionUs)
window.durationUs = durationUs
window.isSeekable = true
window.isDynamic = false
window.liveConfiguration = null
return window
}
override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period {
super.getPeriod(periodIndex, period, setIds)
period.durationUs = durationUs
return period
}
}
}

View file

@ -4,13 +4,10 @@ import android.app.Application;
import android.content.Context; import android.content.Context;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer; import androidx.lifecycle.Observer;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.interfaces.StarCallback; import com.cappielloantonio.tempo.interfaces.StarCallback;
@ -27,7 +24,6 @@ import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.NetworkUtil; import com.cappielloantonio.tempo.util.NetworkUtil;
import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.Preferences;
import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -37,8 +33,8 @@ public class AlbumBottomSheetViewModel extends AndroidViewModel {
private final ArtistRepository artistRepository; private final ArtistRepository artistRepository;
private final FavoriteRepository favoriteRepository; private final FavoriteRepository favoriteRepository;
private final SharingRepository sharingRepository; private final SharingRepository sharingRepository;
private AlbumID3 album; private AlbumID3 album;
private final MutableLiveData<List<Child>> instantMix = new MutableLiveData<>(null);
public AlbumBottomSheetViewModel(@NonNull Application application) { public AlbumBottomSheetViewModel(@NonNull Application application) {
super(application); super(application);
@ -120,7 +116,6 @@ public class AlbumBottomSheetViewModel extends AndroidViewModel {
MutableLiveData<List<Child>> tracksLiveData = albumRepository.getAlbumTracks(album.getId()); MutableLiveData<List<Child>> tracksLiveData = albumRepository.getAlbumTracks(album.getId());
tracksLiveData.observeForever(new Observer<List<Child>>() { tracksLiveData.observeForever(new Observer<List<Child>>() {
@OptIn(markerClass = UnstableApi.class)
@Override @Override
public void onChanged(List<Child> songs) { public void onChanged(List<Child> songs) {
if (songs != null && !songs.isEmpty()) { if (songs != null && !songs.isEmpty()) {
@ -134,12 +129,4 @@ public class AlbumBottomSheetViewModel extends AndroidViewModel {
}); });
} }
} }
public LiveData<List<Child>> getAlbumInstantMix(LifecycleOwner owner, AlbumID3 album) {
instantMix.setValue(Collections.emptyList());
albumRepository.getInstantMix(album, 30).observe(owner, instantMix::postValue);
return instantMix;
}
} }

View file

@ -9,6 +9,7 @@ import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.repository.AlbumRepository; import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3; import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3; import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Constants;
@ -20,6 +21,7 @@ import java.util.List;
public class AlbumListPageViewModel extends AndroidViewModel { public class AlbumListPageViewModel extends AndroidViewModel {
private final AlbumRepository albumRepository; private final AlbumRepository albumRepository;
private final DownloadRepository downloadRepository;
public String title; public String title;
public ArtistID3 artist; public ArtistID3 artist;
@ -30,7 +32,9 @@ public class AlbumListPageViewModel extends AndroidViewModel {
public AlbumListPageViewModel(@NonNull Application application) { public AlbumListPageViewModel(@NonNull Application application) {
super(application); super(application);
albumRepository = new AlbumRepository(); albumRepository = new AlbumRepository();
downloadRepository = new DownloadRepository();
} }
public LiveData<List<AlbumID3>> getAlbumList(LifecycleOwner owner) { public LiveData<List<AlbumID3>> getAlbumList(LifecycleOwner owner) {

View file

@ -8,23 +8,18 @@ import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.repository.AlbumRepository; import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.ArtistRepository; import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.FavoriteRepository;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3; import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.AlbumInfo; import com.cappielloantonio.tempo.subsonic.models.AlbumInfo;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3; import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.util.NetworkUtil;
import java.util.Date;
import java.util.List; import java.util.List;
public class AlbumPageViewModel extends AndroidViewModel { public class AlbumPageViewModel extends AndroidViewModel {
private final AlbumRepository albumRepository; private final AlbumRepository albumRepository;
private final ArtistRepository artistRepository; private final ArtistRepository artistRepository;
private final FavoriteRepository favoriteRepository;
private String albumId; private String albumId;
private String artistId; private String artistId;
private final MutableLiveData<AlbumID3> album = new MutableLiveData<>(null); private final MutableLiveData<AlbumID3> album = new MutableLiveData<>(null);
@ -34,7 +29,6 @@ public class AlbumPageViewModel extends AndroidViewModel {
albumRepository = new AlbumRepository(); albumRepository = new AlbumRepository();
artistRepository = new ArtistRepository(); artistRepository = new ArtistRepository();
favoriteRepository = new FavoriteRepository();
} }
public LiveData<List<Child>> getAlbumSongLiveList() { public LiveData<List<Child>> getAlbumSongLiveList() {
@ -55,61 +49,6 @@ public class AlbumPageViewModel extends AndroidViewModel {
}); });
} }
public void setFavorite() {
AlbumID3 currentAlbum = album.getValue();
if (currentAlbum == null) return;
if (currentAlbum.getStarred() != null) {
if (NetworkUtil.isOffline()) {
removeFavoriteOffline(currentAlbum);
} else {
removeFavoriteOnline(currentAlbum);
}
} else {
if (NetworkUtil.isOffline()) {
setFavoriteOffline(currentAlbum);
} else {
setFavoriteOnline(currentAlbum);
}
}
}
private void removeFavoriteOffline(AlbumID3 album) {
favoriteRepository.starLater(null, album.getId(), null, false);
album.setStarred(null);
this.album.postValue(album);
}
private void removeFavoriteOnline(AlbumID3 album) {
favoriteRepository.unstar(null, album.getId(), null, new StarCallback() {
@Override
public void onError() {
favoriteRepository.starLater(null, album.getId(), null, false);
}
});
album.setStarred(null);
this.album.postValue(album);
}
private void setFavoriteOffline(AlbumID3 album) {
favoriteRepository.starLater(null, album.getId(), null, true);
album.setStarred(new Date());
this.album.postValue(album);
}
private void setFavoriteOnline(AlbumID3 album) {
favoriteRepository.star(null, album.getId(), null, new StarCallback() {
@Override
public void onError() {
favoriteRepository.starLater(null, album.getId(), null, true);
}
});
album.setStarred(new Date());
this.album.postValue(album);
}
public LiveData<ArtistID3> getArtist() { public LiveData<ArtistID3> getArtist() {
return artistRepository.getArtistInfo(artistId); return artistRepository.getArtistInfo(artistId);
} }

View file

@ -4,12 +4,7 @@ import android.app.Application;
import android.content.Context; import android.content.Context;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.interfaces.StarCallback; import com.cappielloantonio.tempo.interfaces.StarCallback;
@ -22,7 +17,6 @@ import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.Preferences;
import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.List; import java.util.List;
@ -30,7 +24,6 @@ import java.util.List;
public class ArtistBottomSheetViewModel extends AndroidViewModel { public class ArtistBottomSheetViewModel extends AndroidViewModel {
private final ArtistRepository artistRepository; private final ArtistRepository artistRepository;
private final FavoriteRepository favoriteRepository; private final FavoriteRepository favoriteRepository;
private final MutableLiveData<List<Child>> instantMix = new MutableLiveData<>(null);
private ArtistID3 artist; private ArtistID3 artist;
@ -102,7 +95,6 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
Log.d("ArtistSync", "Starting artist sync for: " + artist.getName()); Log.d("ArtistSync", "Starting artist sync for: " + artist.getName());
artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() { artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() {
@OptIn(markerClass = UnstableApi.class)
@Override @Override
public void onSongsCollected(List<Child> songs) { public void onSongsCollected(List<Child> songs) {
Log.d("ArtistSync", "Callback triggered with songs: " + (songs != null ? songs.size() : 0)); Log.d("ArtistSync", "Callback triggered with songs: " + (songs != null ? songs.size() : 0));
@ -122,12 +114,5 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
Log.d("ArtistSync", "Artist sync preference is disabled"); Log.d("ArtistSync", "Artist sync preference is disabled");
} }
} }
///
public LiveData<List<Child>> getArtistInstantMix(LifecycleOwner owner, ArtistID3 artist) {
instantMix.setValue(Collections.emptyList());
artistRepository.getInstantMix(artist, 30).observe(owner, instantMix::postValue);
return instantMix;
}
} }

View file

@ -1,37 +1,23 @@
package com.cappielloantonio.tempo.viewmodel; package com.cappielloantonio.tempo.viewmodel;
import android.app.Application; import android.app.Application;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.repository.AlbumRepository; import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.ArtistRepository; import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.FavoriteRepository;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3; import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3; import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistInfo2; import com.cappielloantonio.tempo.subsonic.models.ArtistInfo2;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.NetworkUtil;
import com.cappielloantonio.tempo.util.Preferences;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
public class ArtistPageViewModel extends AndroidViewModel { public class ArtistPageViewModel extends AndroidViewModel {
private final AlbumRepository albumRepository; private final AlbumRepository albumRepository;
private final ArtistRepository artistRepository; private final ArtistRepository artistRepository;
private final FavoriteRepository favoriteRepository;
private ArtistID3 artist; private ArtistID3 artist;
@ -40,7 +26,6 @@ public class ArtistPageViewModel extends AndroidViewModel {
albumRepository = new AlbumRepository(); albumRepository = new AlbumRepository();
artistRepository = new ArtistRepository(); artistRepository = new ArtistRepository();
favoriteRepository = new FavoriteRepository();
} }
public LiveData<List<AlbumID3>> getAlbumList() { public LiveData<List<AlbumID3>> getAlbumList() {
@ -60,7 +45,7 @@ public class ArtistPageViewModel extends AndroidViewModel {
} }
public LiveData<List<Child>> getArtistInstantMix() { public LiveData<List<Child>> getArtistInstantMix() {
return artistRepository.getInstantMix(artist, 30); return artistRepository.getInstantMix(artist, 20);
} }
public ArtistID3 getArtist() { public ArtistID3 getArtist() {
@ -70,70 +55,4 @@ public class ArtistPageViewModel extends AndroidViewModel {
public void setArtist(ArtistID3 artist) { public void setArtist(ArtistID3 artist) {
this.artist = artist; this.artist = artist;
} }
public void setFavorite(Context context) {
if (artist.getStarred() != null) {
if (NetworkUtil.isOffline()) {
removeFavoriteOffline();
} else {
removeFavoriteOnline();
}
} else {
if (NetworkUtil.isOffline()) {
setFavoriteOffline();
} else {
setFavoriteOnline(context);
}
}
}
private void removeFavoriteOffline() {
favoriteRepository.starLater(null, null, artist.getId(), false);
artist.setStarred(null);
}
private void removeFavoriteOnline() {
favoriteRepository.unstar(null, null, artist.getId(), new StarCallback() {
@Override
public void onError() {
favoriteRepository.starLater(null, null, artist.getId(), false);
}
});
artist.setStarred(null);
}
private void setFavoriteOffline() {
favoriteRepository.starLater(null, null, artist.getId(), true);
artist.setStarred(new Date());
}
private void setFavoriteOnline(Context context) {
favoriteRepository.star(null, null, artist.getId(), new StarCallback() {
@Override
public void onError() {
favoriteRepository.starLater(null, null, artist.getId(), true);
}
});
artist.setStarred(new Date());
if (Preferences.isStarredArtistsSyncEnabled()) {
artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() {
@OptIn(markerClass = UnstableApi.class)
@Override
public void onSongsCollected(List<Child> songs) {
if (songs != null && !songs.isEmpty()) {
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownloads(songs),
songs.stream().map(Download::new).collect(Collectors.toList())
);
}
}
});
} else {
Log.d("ArtistSync", "Artist sync preference is disabled");
}
}
} }

View file

@ -24,8 +24,6 @@ import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Playlist; import com.cappielloantonio.tempo.subsonic.models.Playlist;
import com.cappielloantonio.tempo.subsonic.models.Share; import com.cappielloantonio.tempo.subsonic.models.Share;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Constants.SeedType;
import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.Preferences;
import com.google.common.reflect.TypeToken; import com.google.common.reflect.TypeToken;
import com.google.gson.Gson; import com.google.gson.Gson;
@ -36,6 +34,7 @@ import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
public class HomeViewModel extends AndroidViewModel { public class HomeViewModel extends AndroidViewModel {
private static final String TAG = "HomeViewModel"; private static final String TAG = "HomeViewModel";
@ -101,7 +100,7 @@ public class HomeViewModel extends AndroidViewModel {
} }
public LiveData<List<Child>> getRandomShuffleSample() { public LiveData<List<Child>> getRandomShuffleSample() {
return songRepository.getRandomSample(100, null, null); return songRepository.getRandomSample(1000, null, null);
} }
public LiveData<List<Chronology>> getChronologySample(LifecycleOwner owner) { public LiveData<List<Chronology>> getChronologySample(LifecycleOwner owner) {
@ -224,7 +223,7 @@ public class HomeViewModel extends AndroidViewModel {
public LiveData<List<Child>> getMediaInstantMix(LifecycleOwner owner, Child media) { public LiveData<List<Child>> getMediaInstantMix(LifecycleOwner owner, Child media) {
mediaInstantMix.setValue(Collections.emptyList()); mediaInstantMix.setValue(Collections.emptyList());
songRepository.getInstantMix(media.getId(), SeedType.TRACK, 20).observe(owner, mediaInstantMix::postValue); songRepository.getInstantMix(media.getId(), 20).observe(owner, mediaInstantMix::postValue);
return mediaInstantMix; return mediaInstantMix;
} }
@ -249,23 +248,16 @@ public class HomeViewModel extends AndroidViewModel {
pinnedPlaylists.setValue(Collections.emptyList()); pinnedPlaylists.setValue(Collections.emptyList());
playlistRepository.getPlaylists(false, -1).observe(owner, remotes -> { playlistRepository.getPlaylists(false, -1).observe(owner, remotes -> {
if (remotes != null && !remotes.isEmpty()) { playlistRepository.getPinnedPlaylists().observe(owner, locals -> {
List<Playlist> playlists = new ArrayList<>(remotes); if (remotes != null && locals != null) {
String result = Preferences.getHomeSortPlaylists(); List<Playlist> toReturn = remotes.stream()
if (Preferences.getHomeSortPlaylists().equals(Constants.PLAYLIST_ORDER_BY_RANDOM)) .filter(remote -> locals.stream().anyMatch(local -> local.getId().equals(remote.getId())))
{ .collect(Collectors.toList());
Collections.shuffle(playlists);
}
else {
playlists.sort(Comparator.comparing(Playlist::getName));
}
List<Playlist> subsetPlaylists = playlists.size() > 5
? playlists.subList(0, 5)
: playlists;
pinnedPlaylists.setValue(subsetPlaylists); pinnedPlaylists.setValue(toReturn);
} }
}); });
});
return pinnedPlaylists; return pinnedPlaylists;
} }

View file

@ -3,7 +3,6 @@ package com.cappielloantonio.tempo.viewmodel;
import android.app.Application; import android.app.Application;
import android.content.Context; import android.content.Context;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.OptIn; import androidx.annotation.OptIn;
@ -277,7 +276,7 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
public LiveData<List<Child>> getMediaInstantMix(LifecycleOwner owner, Child media) { public LiveData<List<Child>> getMediaInstantMix(LifecycleOwner owner, Child media) {
instantMix.setValue(Collections.emptyList()); instantMix.setValue(Collections.emptyList());
songRepository.getInstantMix(media.getId(), Constants.SeedType.TRACK, 20).observe(owner, instantMix::postValue); songRepository.getInstantMix(media.getId(), 20).observe(owner, instantMix::postValue);
return instantMix; return instantMix;
} }
@ -292,13 +291,13 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
List<String> ids = queue.stream().map(Child::getId).collect(Collectors.toList()); List<String> ids = queue.stream().map(Child::getId).collect(Collectors.toList());
if (media != null) { if (media != null) {
// TODO: We need to get the actual playback position here queueRepository.savePlayQueue(ids, media.getId(), 0);
Log.d(TAG, "Saving play queue - Current: " + media.getId() + ", Items: " + ids.size());
queueRepository.savePlayQueue(ids, media.getId(), 0); // Still hardcoded to 0 for now
return true; return true;
} }
return false; return false;
} }
private void observeCachedLyrics(LifecycleOwner owner, String songId) { private void observeCachedLyrics(LifecycleOwner owner, String songId) {
if (TextUtils.isEmpty(songId)) { if (TextUtils.isEmpty(songId)) {
return; return;

View file

@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.viewmodel;
import android.app.Application; import android.app.Application;
import android.app.Dialog; import android.app.Dialog;
import android.content.SharedPreferences;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.AndroidViewModel;
@ -20,17 +21,8 @@ import java.util.List;
public class PlaylistChooserViewModel extends AndroidViewModel { public class PlaylistChooserViewModel extends AndroidViewModel {
private final PlaylistRepository playlistRepository; private final PlaylistRepository playlistRepository;
private final MutableLiveData<List<Playlist>> playlists = new MutableLiveData<>(null); private final MutableLiveData<List<Playlist>> playlists = new MutableLiveData<>(null);
private final MutableLiveData<Boolean> playlistIsPublic = new MutableLiveData<>(false);
public Boolean getIsPlaylistPublic() {
return playlistIsPublic.getValue();
}
public void setIsPlaylistPublic(boolean isPublic) {
playlistIsPublic.setValue(isPublic);
}
private ArrayList<Child> toAdd = new ArrayList<>(); private ArrayList<Child> toAdd = new ArrayList<>();
public PlaylistChooserViewModel(@NonNull Application application) { public PlaylistChooserViewModel(@NonNull Application application) {
@ -47,7 +39,7 @@ public class PlaylistChooserViewModel extends AndroidViewModel {
public void addSongsToPlaylist(LifecycleOwner owner, Dialog dialog, String playlistId) { public void addSongsToPlaylist(LifecycleOwner owner, Dialog dialog, String playlistId) {
List<String> songIds = Lists.transform(toAdd, Child::getId); List<String> songIds = Lists.transform(toAdd, Child::getId);
if (Preferences.allowPlaylistDuplicates()) { if (Preferences.allowPlaylistDuplicates()) {
playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds), getIsPlaylistPublic()); playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds));
dialog.dismiss(); dialog.dismiss();
} else { } else {
playlistRepository.getPlaylistSongs(playlistId).observe(owner, playlistSongs -> { playlistRepository.getPlaylistSongs(playlistId).observe(owner, playlistSongs -> {
@ -55,7 +47,7 @@ public class PlaylistChooserViewModel extends AndroidViewModel {
List<String> playlistSongIds = Lists.transform(playlistSongs, Child::getId); List<String> playlistSongIds = Lists.transform(playlistSongs, Child::getId);
songIds.removeAll(playlistSongIds); songIds.removeAll(playlistSongIds);
} }
playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds), getIsPlaylistPublic()); playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds));
dialog.dismiss(); dialog.dismiss();
}); });
} }

View file

@ -20,36 +20,14 @@ public class PlaylistPageViewModel extends AndroidViewModel {
private Playlist playlist; private Playlist playlist;
private boolean isOffline; private boolean isOffline;
private final MutableLiveData<List<Child>> songLiveList = new MutableLiveData<>();
public PlaylistPageViewModel(@NonNull Application application) { public PlaylistPageViewModel(@NonNull Application application) {
super(application); super(application);
playlistRepository = new PlaylistRepository(); playlistRepository = new PlaylistRepository();
playlistRepository.getPlaylistUpdateTrigger().observeForever(needsRefresh -> {
if (needsRefresh != null && needsRefresh && playlist != null) {
refreshSongs();
}
});
} }
public LiveData<List<Child>> getPlaylistSongLiveList() { public LiveData<List<Child>> getPlaylistSongLiveList() {
if (songLiveList.getValue() == null && playlist != null) { return playlistRepository.getPlaylistSongs(playlist.getId());
refreshSongs();
}
return songLiveList;
}
private void refreshSongs() {
if (playlist == null) return;
LiveData<List<Child>> remoteData = playlistRepository.getPlaylistSongs(playlist.getId());
remoteData.observeForever(new androidx.lifecycle.Observer<List<Child>>() {
@Override
public void onChanged(List<Child> songs) {
songLiveList.postValue(songs);
remoteData.removeObserver(this);
}
});
} }
public Playlist getPlaylist() { public Playlist getPlaylist() {
@ -57,10 +35,7 @@ public class PlaylistPageViewModel extends AndroidViewModel {
} }
public void setPlaylist(Playlist playlist) { public void setPlaylist(Playlist playlist) {
if (this.playlist == null || !this.playlist.getId().equals(playlist.getId())) {
this.playlist = playlist; this.playlist = playlist;
this.songLiveList.setValue(null); // Clear old data immediately
}
} }
public LiveData<Boolean> isPinned(LifecycleOwner owner) { public LiveData<Boolean> isPinned(LifecycleOwner owner) {

View file

@ -1,24 +1,14 @@
package com.cappielloantonio.tempo.viewmodel; package com.cappielloantonio.tempo.viewmodel;
import android.app.Application; import android.app.Application;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.AndroidViewModel;
import com.cappielloantonio.tempo.repository.PodcastRepository; import com.cappielloantonio.tempo.repository.PodcastRepository;
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
import com.cappielloantonio.tempo.subsonic.models.PodcastChannel; import com.cappielloantonio.tempo.subsonic.models.PodcastChannel;
import java.io.IOException;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class PodcastChannelBottomSheetViewModel extends AndroidViewModel { public class PodcastChannelBottomSheetViewModel extends AndroidViewModel {
private static final String TAG = "PodcastChannelBottomSheetViewModel";
private final PodcastRepository podcastRepository; private final PodcastRepository podcastRepository;
private PodcastChannel podcastChannel; private PodcastChannel podcastChannel;
@ -38,59 +28,6 @@ public class PodcastChannelBottomSheetViewModel extends AndroidViewModel {
} }
public void deletePodcastChannel() { public void deletePodcastChannel() {
if (podcastChannel != null && podcastChannel.getId() != null) { if (podcastChannel != null) podcastRepository.deletePodcastChannel(podcastChannel.getId());
podcastRepository.deletePodcastChannel(podcastChannel.getId())
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.code() == 501) {
Toast.makeText(getApplication(),
"Podcasts are not supported by this server",
Toast.LENGTH_LONG).show();
return;
} }
if (response.isSuccessful() && response.body() != null) {
ApiResponse apiResponse = response.body();
String status = apiResponse.subsonicResponse.getStatus();
if ("ok".equals(status)) {
Toast.makeText(getApplication(),
"Podcast channel deleted",
Toast.LENGTH_SHORT).show();
//TODO refresh the UI after deleting
//podcastRepository.refreshPodcasts();
}
} else {
handleHttpError(response);
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Toast.makeText(getApplication(),
"Network error: " + t.getMessage(),
Toast.LENGTH_LONG).show();
}
});
}
}
private void handleHttpError(Response<ApiResponse> response) {
String errorMsg = "HTTP error: " + response.code();
if (response.errorBody() != null) {
try {
String serverMsg = response.errorBody().string();
if (!serverMsg.isEmpty()) {
errorMsg += " - " + serverMsg;
}
} catch (IOException e) {
Log.e(TAG, "Error reading error body", e);
}
}
Toast.makeText(getApplication(), errorMsg, Toast.LENGTH_LONG).show();
}
} }

View file

@ -1,99 +1,27 @@
package com.cappielloantonio.tempo.viewmodel; package com.cappielloantonio.tempo.viewmodel;
import android.app.Application; import android.app.Application;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.repository.PodcastRepository; import com.cappielloantonio.tempo.repository.PodcastRepository;
import com.cappielloantonio.tempo.subsonic.base.ApiResponse; import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
import java.io.IOException;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class PodcastChannelEditorViewModel extends AndroidViewModel { public class PodcastChannelEditorViewModel extends AndroidViewModel {
private static final String TAG = "PodcastChannelEditorViewModel"; private static final String TAG = "RadioEditorViewModel";
private final PodcastRepository podcastRepository; private final PodcastRepository podcastRepository;
private final MutableLiveData<Boolean> isSuccess = new MutableLiveData<>(false); private InternetRadioStation toEdit;
private final MutableLiveData<String> errorMessage = new MutableLiveData<>();
public PodcastChannelEditorViewModel(@NonNull Application application) { public PodcastChannelEditorViewModel(@NonNull Application application) {
super(application); super(application);
podcastRepository = new PodcastRepository(); podcastRepository = new PodcastRepository();
} }
public LiveData<Boolean> getIsSuccess() {
return isSuccess;
}
public LiveData<String> getErrorMessage() {
return errorMessage;
}
public void clearError() {
errorMessage.setValue(null);
}
public void createChannel(String url) { public void createChannel(String url) {
errorMessage.setValue(null); podcastRepository.createPodcastChannel(url);
isSuccess.setValue(false);
podcastRepository.createPodcastChannel(url)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.code() == 501) {
showError(getApplication().getString(R.string.podcast_channel_not_supported_snackbar));
return;
}
if (response.isSuccessful() && response.body() != null) {
ApiResponse apiResponse = response.body();
String status = apiResponse.subsonicResponse.getStatus();
if ("ok".equals(status)) {
isSuccess.setValue(true);
}
} else {
handleHttpError(response);
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
showError("Network error: " + t.getMessage());
Log.e(TAG, "Network error", t);
}
});
}
private void handleHttpError(Response<ApiResponse> response) {
String errorMsg = "HTTP error: " + response.code();
if (response.errorBody() != null) {
try {
String serverMsg = response.errorBody().string();
if (!serverMsg.isEmpty()) {
errorMsg += " - " + serverMsg;
}
} catch (IOException e) {
Log.e(TAG, "Error reading error body", e);
}
}
showError(errorMsg);
}
private void showError(String message) {
Toast.makeText(getApplication(), message, Toast.LENGTH_LONG).show();
errorMessage.setValue(message);
Log.e(TAG, "Error shown: " + message);
} }
} }

View file

@ -1,47 +1,26 @@
package com.cappielloantonio.tempo.viewmodel; package com.cappielloantonio.tempo.viewmodel;
import android.app.Application; import android.app.Application;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.repository.RadioRepository; import com.cappielloantonio.tempo.repository.RadioRepository;
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation; import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
import java.io.IOException;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class RadioEditorViewModel extends AndroidViewModel { public class RadioEditorViewModel extends AndroidViewModel {
private static final String TAG = "RadioEditorViewModel"; private static final String TAG = "RadioEditorViewModel";
private final RadioRepository radioRepository; private final RadioRepository radioRepository;
private InternetRadioStation toEdit;
private final MutableLiveData<Boolean> isSuccess = new MutableLiveData<>(false); private InternetRadioStation toEdit;
private final MutableLiveData<String> errorMessage = new MutableLiveData<>();
public RadioEditorViewModel(@NonNull Application application) { public RadioEditorViewModel(@NonNull Application application) {
super(application); super(application);
radioRepository = new RadioRepository(); radioRepository = new RadioRepository();
} }
public LiveData<Boolean> getIsSuccess() { return isSuccess; }
public LiveData<String> getErrorMessage() { return errorMessage; }
public void clearError() {
errorMessage.setValue(null);
}
public InternetRadioStation getRadioToEdit() { public InternetRadioStation getRadioToEdit() {
return toEdit; return toEdit;
} }
@ -51,120 +30,14 @@ public class RadioEditorViewModel extends AndroidViewModel {
} }
public void createRadio(String name, String streamURL, String homepageURL) { public void createRadio(String name, String streamURL, String homepageURL) {
errorMessage.setValue(null); radioRepository.createInternetRadioStation(name, streamURL, homepageURL);
isSuccess.setValue(false);
radioRepository.createInternetRadioStation(name, streamURL, homepageURL)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
// Handle HTTP 501 (Not Implemented) from Navidrome
if (response.code() == 501) {
showError(getApplication().getString(R.string.radio_dialog_not_supported_snackbar));
return;
}
if (response.isSuccessful() && response.body() != null) {
ApiResponse apiResponse = response.body();
String status = apiResponse.subsonicResponse.getStatus();
if ("ok".equals(status)) {
isSuccess.setValue(true);
} else if ("failed".equals(status)) {
handleFailedResponse(apiResponse);
}
} else {
errorMessage.setValue("HTTP error: " + response.code());
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
errorMessage.setValue("Network error: " + t.getMessage());
}
});
} }
public void updateRadio(String name, String streamURL, String homepageURL) { public void updateRadio(String name, String streamURL, String homepageURL) {
if (toEdit != null && toEdit.getId() != null) { if (toEdit != null) radioRepository.updateInternetRadioStation(toEdit.getId(), name, streamURL, homepageURL);
errorMessage.setValue(null);
isSuccess.setValue(false);
radioRepository.updateInternetRadioStation(toEdit.getId(), name, streamURL, homepageURL)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse apiResponse = response.body();
if (apiResponse.subsonicResponse != null) {
String status = apiResponse.subsonicResponse.getStatus();
if ("ok".equals(status)) {
isSuccess.setValue(true);
} else if ("failed".equals(status)) {
handleFailedResponse(apiResponse);
}
}
} else {
errorMessage.setValue("HTTP error: " + response.code());
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
errorMessage.setValue("Network error: " + t.getMessage());
}
});
}
} }
public void deleteRadio() { public void deleteRadio() {
if (toEdit != null && toEdit.getId() != null) { if (toEdit != null) radioRepository.deleteInternetRadioStation(toEdit.getId());
errorMessage.setValue(null);
isSuccess.setValue(false);
radioRepository.deleteInternetRadioStation(toEdit.getId())
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse apiResponse = response.body();
String status = apiResponse.subsonicResponse.getStatus();
if ("ok".equals(status)) {
isSuccess.setValue(true);
} else if ("failed".equals(status)) {
handleFailedResponse(apiResponse);
}
} else {
errorMessage.setValue("HTTP error: " + response.code());
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
errorMessage.setValue("Network error: " + t.getMessage());
}
});
}
}
private void showError(String message) {
Toast.makeText(getApplication(), message, Toast.LENGTH_LONG).show();
errorMessage.setValue(message);
}
private void handleFailedResponse(ApiResponse apiResponse) {
String errorMsg = "Unknown server error";
if (apiResponse.subsonicResponse.getError() != null) {
errorMsg = apiResponse.subsonicResponse.getError().getMessage();
if ("Not implemented".equals(errorMsg)) {
errorMsg = getApplication().getString((R.string.radio_dialog_not_supported_snackbar));
}
}
Toast.makeText(getApplication(), errorMsg, Toast.LENGTH_LONG).show();
errorMessage.setValue(errorMsg);
} }
} }

View file

@ -48,11 +48,11 @@ public class SearchViewModel extends AndroidViewModel {
} }
public void insertNewSearch(String search) { public void insertNewSearch(String search) {
searchingRepository.insert(new RecentSearch(search, System.currentTimeMillis() / 1000L)); searchingRepository.insert(new RecentSearch(search));
} }
public void deleteRecentSearch(String search) { public void deleteRecentSearch(String search) {
searchingRepository.delete(new RecentSearch(search, 0)); searchingRepository.delete(new RecentSearch(search));
} }
public LiveData<List<String>> getSearchSuggestion(String query) { public LiveData<List<String>> getSearchSuggestion(String query) {

View file

@ -10,20 +10,17 @@ import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.MutableLiveData;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.interfaces.MediaCallback;
import com.cappielloantonio.tempo.interfaces.StarCallback; import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.repository.AlbumRepository; import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.ArtistRepository; import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.FavoriteRepository; import com.cappielloantonio.tempo.repository.FavoriteRepository;
import com.cappielloantonio.tempo.repository.PlaylistRepository;
import com.cappielloantonio.tempo.repository.SharingRepository; import com.cappielloantonio.tempo.repository.SharingRepository;
import com.cappielloantonio.tempo.repository.SongRepository; import com.cappielloantonio.tempo.repository.SongRepository;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3; import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3; import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Share; import com.cappielloantonio.tempo.subsonic.models.Share;
import com.cappielloantonio.tempo.util.Constants.SeedType;
import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.NetworkUtil; import com.cappielloantonio.tempo.util.NetworkUtil;
@ -40,7 +37,6 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
private final ArtistRepository artistRepository; private final ArtistRepository artistRepository;
private final FavoriteRepository favoriteRepository; private final FavoriteRepository favoriteRepository;
private final SharingRepository sharingRepository; private final SharingRepository sharingRepository;
private final PlaylistRepository playlistRepository;
private Child song; private Child song;
@ -54,7 +50,6 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
artistRepository = new ArtistRepository(); artistRepository = new ArtistRepository();
favoriteRepository = new FavoriteRepository(); favoriteRepository = new FavoriteRepository();
sharingRepository = new SharingRepository(); sharingRepository = new SharingRepository();
playlistRepository = new PlaylistRepository();
} }
public Child getSong() { public Child getSong() {
@ -65,10 +60,6 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
this.song = song; this.song = song;
} }
public void removeFromPlaylist(String playlistId, int index, PlaylistRepository.AddToPlaylistCallback callback) {
playlistRepository.removeSongFromPlaylist(playlistId, index, callback);
}
public void setFavorite(Context context) { public void setFavorite(Context context) {
if (song.getStarred() != null) { if (song.getStarred() != null) {
if (NetworkUtil.isOffline()) { if (NetworkUtil.isOffline()) {
@ -137,22 +128,11 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
public LiveData<List<Child>> getInstantMix(LifecycleOwner owner, Child media) { public LiveData<List<Child>> getInstantMix(LifecycleOwner owner, Child media) {
instantMix.setValue(Collections.emptyList()); instantMix.setValue(Collections.emptyList());
songRepository.getInstantMix(media.getId(), SeedType.TRACK, 30).observe(owner, instantMix::postValue); songRepository.getInstantMix(media.getId(), 20).observe(owner, instantMix::postValue);
return instantMix; return instantMix;
} }
public void getInstantMix(Child media, int count, MediaCallback callback) {
songRepository.getInstantMix(media.getId(), SeedType.TRACK, count, songs -> {
if (songs != null && !songs.isEmpty()) {
callback.onLoadMedia(songs);
} else {
callback.onLoadMedia(Collections.emptyList());
}
});
}
public MutableLiveData<Share> shareTrack() { public MutableLiveData<Share> shareTrack() {
return sharingRepository.createShare(song.getId(), song.getTitle(), null); return sharingRepository.createShare(song.getId(), song.getTitle(), null);
} }

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M720,800L720,680L600,680L600,600L720,600L720,480L800,480L800,600L920,600L920,680L800,680L800,800L720,800ZM120,840Q87,840 63.5,816.5Q40,793 40,760L40,200Q40,167 63.5,143.5Q87,120 120,120L680,120Q713,120 736.5,143.5Q760,167 760,200L760,400L680,400L680,320L120,320L120,760Q120,760 120,760Q120,760 120,760L640,760L640,840L120,840ZM120,240L680,240L680,200Q680,200 680,200Q680,200 680,200L120,200Q120,200 120,200Q120,200 120,200L120,240ZM120,240L120,200Q120,200 120,200Q120,200 120,200L120,200Q120,200 120,200Q120,200 120,200L120,240Z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,468Q821,459 801,452.5Q781,446 760,443L760,200Q760,200 760,200Q760,200 760,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L442,760Q445,782 451.5,802Q458,822 467,840L200,840ZM200,720Q200,731 200,740.5Q200,750 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200Q200,200 200,200Q200,200 200,200L200,443Q200,441 200,440.5Q200,440 200,440Q200,440 200,522Q200,604 200,720ZM280,680L443,680Q446,659 452.5,639Q459,619 467,600L280,600L280,680ZM280,520L524,520Q556,490 595.5,470Q635,450 680,443L680,440L280,440L280,520ZM280,360L680,360L680,280L280,280L280,360ZM720,920Q637,920 578.5,861.5Q520,803 520,720Q520,637 578.5,578.5Q637,520 720,520Q803,520 861.5,578.5Q920,637 920,720Q920,803 861.5,861.5Q803,920 720,920ZM700,840L740,840L740,740L840,740L840,700L740,700L740,600L700,600L700,700L600,700L600,740L700,740L700,840Z"/>
</vector>

View file

@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:viewportHeight="960"
android:viewportWidth="960"
android:width="24dp">
<path
android:fillColor="@android:color/white"
android:pathData="M480,660Q555,660 607.5,607.5Q660,555 660,480Q660,405 607.5,352.5Q555,300 480,300Q405,300 352.5,352.5Q300,405 300,480Q300,555 352.5,607.5Q405,660 480,660ZM451.5,508.5Q440,497 440,480Q440,463 451.5,451.5Q463,440 480,440Q497,440 508.5,451.5Q520,463 520,480Q520,497 508.5,508.5Q497,520 480,520Q463,520 451.5,508.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View file

@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:viewportHeight="960"
android:viewportWidth="960"
android:width="24dp">
<path
android:fillColor="@android:color/white"
android:pathData="M740,400L880,400L880,480L800,480L800,700Q800,742 771,771Q742,800 700,800Q658,800 629,771Q600,742 600,700Q600,658 629,629Q658,600 700,600Q708,600 718,601.5Q728,603 740,608L740,400ZM120,800L120,688Q120,653 137.5,625Q155,597 184,582Q246,551 310,535.5Q374,520 440,520Q482,520 523.5,526.5Q565,533 607,546Q587,558 571,575Q555,592 543,612Q517,606 491.5,603Q466,600 440,600Q383,600 328,614Q273,628 220,654Q211,659 205.5,668Q200,677 200,688L200,720L521,720Q523,740 530.5,760Q538,780 551,800L120,800ZM327,433Q280,386 280,320Q280,254 327,207Q374,160 440,160Q506,160 553,207Q600,254 600,320Q600,386 553,433Q506,480 440,480Q374,480 327,433ZM496.5,376.5Q520,353 520,320Q520,287 496.5,263.5Q473,240 440,240Q407,240 383.5,263.5Q360,287 360,320Q360,353 383.5,376.5Q407,400 440,400Q473,400 496.5,376.5ZM440,320Q440,320 440,320Q440,320 440,320Q440,320 440,320Q440,320 440,320Q440,320 440,320Q440,320 440,320Q440,320 440,320Q440,320 440,320ZM440,720L440,720L440,720Q440,720 440,720Q440,720 440,720Q440,720 440,720Q440,720 440,720Q440,720 440,720Q440,720 440,720Q440,720 440,720Q440,720 440,720Z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M120,840Q87,840 63.5,816.5Q40,793 40,760L40,240L120,240L120,760Q120,760 120,760Q120,760 120,760L800,760L800,840L120,840ZM280,680Q247,680 223.5,656.5Q200,633 200,600L200,160Q200,127 223.5,103.5Q247,80 280,80L480,80L560,160L840,160Q873,160 896.5,183.5Q920,207 920,240L920,600Q920,633 896.5,656.5Q873,680 840,680L280,680ZM280,600L840,600Q840,600 840,600Q840,600 840,600L840,240Q840,240 840,240Q840,240 840,240L527,240L447,160L280,160Q280,160 280,160Q280,160 280,160L280,600Q280,600 280,600Q280,600 280,600ZM280,600Q280,600 280,600Q280,600 280,600L280,160Q280,160 280,160Q280,160 280,160L280,160L280,240L280,240Q280,240 280,240Q280,240 280,240L280,600Q280,600 280,600Q280,600 280,600Z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M649,463.5Q737,447 800,420L800,820Q740,847 654,863.5Q568,880 480,880Q392,880 306,863.5Q220,847 160,820L160,420Q223,447 311,463.5Q399,480 480,480Q561,480 649,463.5ZM720,760L720,530Q670,544 604.5,552Q539,560 480,560Q421,560 355.5,552Q290,544 240,530L240,760Q290,778 355,789Q420,800 480,800Q540,800 605,789Q670,778 720,760ZM593,127Q640,174 640,240Q640,306 593,353Q546,400 480,400Q414,400 367,353Q320,306 320,240Q320,174 367,127Q414,80 480,80Q546,80 593,127ZM536.5,296.5Q560,273 560,240Q560,207 536.5,183.5Q513,160 480,160Q447,160 423.5,183.5Q400,207 400,240Q400,273 423.5,296.5Q447,320 480,320Q513,320 536.5,296.5ZM480,240Q480,240 480,240Q480,240 480,240Q480,240 480,240Q480,240 480,240Q480,240 480,240Q480,240 480,240Q480,240 480,240Q480,240 480,240ZM480,665Q480,665 480,665Q480,665 480,665Q480,665 480,665Q480,665 480,665L480,665Q480,665 480,665Q480,665 480,665Q480,665 480,665Q480,665 480,665Z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M240,760L360,760L360,520L600,520L600,760L720,760L720,400L480,220L240,400L240,760ZM160,840L160,360L480,120L800,360L800,840L520,840L520,600L440,600L440,840L160,840ZM480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490Z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M400,640L640,480L400,320L400,640ZM324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,437 89,395.5Q98,354 115,315L177,377Q169,403 164.5,428.5Q160,454 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q453,160 427.5,164.5Q402,169 377,177L316,116Q356,98 396,89Q436,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Q397,880 324,848.5ZM177.5,262.5Q160,245 160,220Q160,195 177.5,177.5Q195,160 220,160Q245,160 262.5,177.5Q280,195 280,220Q280,245 262.5,262.5Q245,280 220,280Q195,280 177.5,262.5ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M120,880L120,800L840,800L840,880L120,880ZM120,640L120,560L840,560L840,640L120,640ZM120,400L120,320L840,320L840,400L120,400ZM120,160L120,80L840,80L840,160L120,160Z"/>
</vector>

View file

@ -1,5 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#FFFFFF" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M120,640L120,560L440,560L440,640L120,640ZM120,480L120,400L600,400L600,480L120,480ZM120,320L120,240L600,240L600,320L120,320ZM640,840L640,520L880,680L640,840Z"/>
</vector>

Some files were not shown because too many files have changed in this diff Show more