343 Commits

Author SHA1 Message Date
eddyizm
a179db6323 fix: set gh pages to use the same flavor as github to address #464 2026-03-29 08:51:36 -07:00
eddyizm
1beeab28a6 chore: updated log, fastlane docs, bumped version, updated screenshots 2026-03-25 20:35:26 -07:00
unknown0816
ad6a569961 Added all-songs feature (#517)
Co-authored-by: Unknown0816 <Unknown0816@github.com>
2026-03-25 07:25:04 -07:00
Jorilx
0f5a8f6b97 Add 'genres' page/function to Android Auto (#505)
* Add 'genres' page/function to Android Auto

* Add 'genres' string to multilingual files that use aa_tab_titles and aa_tab_values

* Updated USAGE.md

* Add preference to shuffle songs on the 'genre' page

* Updated USAGE.md
2026-03-25 07:13:24 -07:00
eddyizm
3cd1bdf229 feat: logo refresh (#498)
* wip: working on new logo

* feat: removing old webp and using new vector icon. testing adaptive monochrome

* fix: adjusted launcher and splash scale size

* fix: got both variants matching up size wise

* fix: added android tranparent

* fixed red icon scaling

* fix: updated with a less crappy monochrome thanks to mr seattle guy!

* feat: updated degoogled color, added the proper background launcher, added png icons and banner

* chore: updated readme with new logo credit
2026-03-23 22:18:54 -07:00
Tom
d7389db265 refactor: navigation and bottom sheet (#491)
* feat: enhance navigation

* fix: leaving settings always unlocks drawer

* feat: set app settings inside a frame layout

In order to add a toolbar with a back button in settings I needed to extend from a fragment
so I converted SettingsFragment into a fragment and created SettingsContainerFragment,
the latter is injected as a child of SettingsFragment inside a FrameLayout.

Since SettingsContainerFragment extends from PreferenceFragmentCompat, this allows
to swap it for other and, in the bigger picture, allow an arbitrary organization.

* fix: onStop declaration on wrong class

* fix: equalizer not respecting navigation ui directives

* Revert "fix: equalizer not respecting navigation ui directives"

This reverts commit eeb125542d.

* fix: navbar + bottom sheet behavior on equalizer fragment

* refactor: delegate navigation to controller and helper

* Revert "fix: onStop declaration on wrong class"

This reverts commit 34d354d803.

* Revert "feat: set app settings inside a frame layout"

This reverts commit 52cfd36b09.

* chore: set experimental label to settings title

Hide bottom navigation bar on portrait and unlock drawer on portrait

* refactor: move controller to dedicated pakckage

* fix: remove old navigation controller delegate

* feat: stabilize public methods and their implementations

* feat: migrate to new navigation controller

* feat: remove unnecessary global variables

* refactor: set controller pattern to bottom sheet

* feat: set app settings inside a frame layout

In order to add a toolbar with a back button in settings I needed to extend from a fragment
so I converted SettingsFragment into a fragment and created SettingsContainerFragment,
the latter is injected as a child of SettingsFragment inside a FrameLayout.

Since SettingsContainerFragment extends from PreferenceFragmentCompat, this allows
to swap it for other and, in the bigger picture, allow an arbitrary organization.

* fix: onStop declaration on wrong class

* feat: add back button to settings view

---------

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-03-23 22:16:51 -07:00
skajmer
b3c93b3885 chore(i18n): Update Polish translation (#516)
* Add #457

* Add #450

* Add #458

* Add #440 (strings.xml)

* Add #440 and #437 (arrays.xml)

* Add #437

* tracks not songs

* comments are not needed here
2026-03-22 11:10:48 -07:00
Jaime García
25864accc9 fix: Relocate "Offline mode" text (#510)
Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-03-19 21:40:59 -07:00
Jaime García
f734ced2cb chore(i18n): Update Spanish translation (#509) 2026-03-19 21:38:11 -07:00
Jaime García
4a3d2305c0 fix: Show full album name when displaying details (#508) 2026-03-19 21:32:50 -07:00
eddyizm
8db6797eaa fix: duplicate line in fr settings 2026-03-16 22:03:36 -07:00
Oliver Tzeng
cb4c19757d Translated to zh_TW (#494)
* translated to zh_TW

* support zh-TW

* fixed 'apply'

---------

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-03-16 21:45:16 -07:00
MaFo-28
b6e75afe12 feat: tile size manager (#440)
* Add TileSizeManager and improve dynamic tile sizing

* Improve scale labels

* Add protection against invalid tile size preferences

* Fix DiscoverSongAdapter & move TileSizeManager
2026-03-14 08:04:47 -07:00
Artyom
b621be06df Improve Russian translation (#503)
* Translate to ru and fix arrays

* Update translate strings to ru

* Update made_for_you
2026-03-14 07:50:36 -07:00
eddyizm
7a17e91690 chore: bumped version and updated change log for release 2026-03-06 19:21:54 -08:00
Tom
1036829186 fix: collapse sheet on navitation change (#482)
Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-03-06 19:00:01 -08:00
Tom
becfc1d589 fix: remove material you dynamic theming (#484)
PR #466 required a dynamic theming macro to set the color, however
if the device does not support that feature the app crashes after logging in

The dynamic theming macro has been replaced with a standard material component

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-03-06 07:43:13 -08:00
skajmer
44bf346332 chore(i18n): Update Polish translation (#483)
* Add #457

* Add #450

* Add #458
2026-03-04 07:40:37 -08:00
MaFo-28
896e5fb3bd doc: update USAGE with android auto configuration (#481)
* doc: update USAGE with android auto configuration

* Update USAGE.md
2026-03-02 12:33:09 -08:00
eddyizm
3086a8b9f9 chore: bumped version for build fix 2026-03-01 20:20:08 -08:00
eddyizm
10c2172be0 fix: updated constraints causing fata lint build failures (#478) 2026-03-01 20:19:05 -08:00
eddyizm
918bf6928e chore: bumped version and change log for release 2026-03-01 19:59:28 -08:00
Tom
c9cf86acb5 feat: toggle player bitrate visibility on touch (#466)
* feat: touch player chip to toggle bitrate visibility

* feat: player bitrate visibility is remembered

* fix: player landscape layout not grouping chip with textview

* feat: touch bitrate to toggle its visibility

This catches the edge case where the the chip is not reachable due to insuficient horizontal space

---------

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-03-01 19:48:15 -08:00
eddyizm
0487f3bb9b fix: returns filtered list and reset correctly (#476) 2026-03-01 19:36:48 -08:00
Tom
c7f2524085 feat: feat: advertise existing long press to refresh per section (#467)
* feat: advertise existing long press to refresh per section

---------

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-03-01 19:36:03 -08:00
eddyizm
88c2129cd4 chore: bumping version for release 2026-02-28 09:07:59 -08:00
Angelo Suzuki
aa5d0f92db Support specifying a client certificate for mTLS auth (#458)
* feat: collect and save client certificate

* feat: use client certificate for Retrofit, Glide and ExoPlayer

---------

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-02-26 21:20:01 -08:00
MaFo-28
3ba2255205 Android Auto: improve media service browsing (#437)
* Add Android Auto icons and improve media service browsing

* chore: changelog and build updated for release

* add grid/list setting for playlist, podcast and radio

---------

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-02-26 21:09:49 -08:00
Tom
145bb82eb0 feat: enhance navigation (#450)
* feat: enhance navigation

* fix: leaving settings always unlocks drawer

* feat: set app settings inside a frame layout

In order to add a toolbar with a back button in settings I needed to extend from a fragment
so I converted SettingsFragment into a fragment and created SettingsContainerFragment,
the latter is injected as a child of SettingsFragment inside a FrameLayout.

Since SettingsContainerFragment extends from PreferenceFragmentCompat, this allows
to swap it for other and, in the bigger picture, allow an arbitrary organization.

* fix: onStop declaration on wrong class

* fix: equalizer not respecting navigation ui directives

* Revert "fix: equalizer not respecting navigation ui directives"

This reverts commit eeb125542d.

* fix: navbar + bottom sheet behavior on equalizer fragment

* Revert "fix: onStop declaration on wrong class"

This reverts commit 34d354d803.

* Revert "feat: set app settings inside a frame layout"

This reverts commit 52cfd36b09.

* chore: set experimental label to settings title

Hide bottom navigation bar on portrait and unlock drawer on portrait
2026-02-26 07:14:42 -08:00
Tom
932d1aaa8c fix: artist sort by name case sensitive (#462) 2026-02-25 17:40:50 -08:00
Tom
4f8212d491 Port remove song of playlist from tempus ng (#457)
* feat: implement track removal from playlists with real-time UI updates

- Added 'Remove from playlist' option to song bottom sheet (appears only when inside a playlist)
- Implemented immediate UI refresh for track count and duration in playlist header
- Fixed a bug where shuffling for covers scrambled the actual playlist song order
- Improved PlaylistPageViewModel to clear stale data and handle isolated updates correctly
- Added dedicated success/failure messages for track removal in English and Italian
- Unified heart icon size to 14dp across all track list items

* fix: missing code from port process

The cherry-pick was missing the database getter
and the function to remove a song from a playlist

---------

Co-authored-by: beeetfarmer <176325048+beeetfarmer@users.noreply.github.com>
2026-02-25 11:37:43 -08:00
Denis Machard
b403d69982 feat: radio logos support for AndroidAuto (#435)
* feat: radio logos support for AndroidAuto

* resolve a merge conflict.

* fix auto lint

* fix auto lint

* fix auto break line

* fix auto break line

* fix auto break line

* fix: add alternate serialized name for InternetRadioStation homePageUrl to support both `homePageUrl` and `homepageUrl` JSON keys.

* improve internet radio station cover art handling by prioritizing home page URLs

* fix: remove unnecessary blank line and adjust formatting in MusicUtil

* refactor: improve formatting and clean up whitespace in MappingUtil and MusicUtil
2026-02-22 08:08:01 -08:00
eddyizm
a49f2b97a2 Merge branch 'main' into development 2026-02-21 22:12:56 -08:00
skajmer
c44e60c0e5 chore(i18n): Update Polish translation (#441)
* Add #338

* Add #3700 (strings.xml)

* Add #370 (arrays.xml)

* Add #386

* Add #394

* Add #411 and #413

* Add #411 (arrays)

* misspelling
2026-02-16 09:45:57 -08:00
eddyizm
4cd15b4284 chore: changelog and build updated for release 2026-02-15 10:35:22 -08:00
eddyizm
72d7aea6e3 fix: release build errors 2026-02-15 10:30:01 -08:00
Tom
9adaf8c013 feat: improve playlist chooser dialog UI (#439)
* fix: lock buttons at dialog bottom

The previous implementation appended the buttons to the RecyclerView programmatically
this disabled the scroll and pushed the buttons outside the visible dialog area
if too there were too many playlists.

To fix this now the XML defines a fixed location for the buttons, enabling
the scroll of the RecyclerView and preventing the buttons to become unreachable

* feat: improve playlist chooser dialog UI

Implement it in the XML layout and not programmatically.

* fix: detached listeners from XML layout

* fix: missing dialog title
2026-02-15 09:42:07 -08:00
TrackArcher
661346ca3a feat: radio metadata (#352)
* feat: support dynamic metadata for internet radio stations

- Implemented `onMetadata` in `BaseMediaService` to extract "Artist - Title" info from ICY, ID3, and Vorbis streams.
- Added a fallback mechanism to periodically check HTTP headers (e.g., `icy-name`, `StreamTitle`) for radio metadata.
- Updated `PlayerControllerFragment` and `TrackInfoDialog` to display the station name alongside dynamic track information.
- Enhanced `TrackInfoDialog` layout to include a dedicated "Station" field for radio tracks.
- Modified `MappingUtil` to preserve station names in media metadata extras.

* fix crashing issue

* radio bob metadata works now. fix crashing issue

* Fixing unchecked operation warnings in SongHorizontalAdapter.java.

* optimizing a bit and better format for notification

* removed xml files affecting build and enviroment

* removed xml files affecting build and enviroment

* fix ui internet radio bottomview

* Revert "fix ui internet radio bottomview"

This reverts commit c237ed451f.

* rebased to upstream/development and fixed metadata to show up for radio after the rebase

* misc.xml restored

* Apply suggestion from @eddyizm

---------

Co-authored-by: eddyizm <wtfisup@hotmail.com>
Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-02-15 08:03:00 -08:00
eddyizm
dbd32baa12 feat: prefer locally downloaded media vs server stream (#433)
resolves #404 and should address #285
2026-02-11 21:31:46 -08:00
Tom
3958cbcc1c fix: local url used in share link instead of server url (#431)
fix: use explicitly Server Public URl in link sharing
2026-02-09 20:02:15 -08:00
Tom
fb568d1d74 fix: speed button overlaps with shuffle on landscape (#430)
fix: buttons overlap on landscape player
2026-02-09 20:01:02 -08:00
Denis Machard
e06a168350 fix: radio playback "source error" on android auto (#426) 2026-02-09 20:00:33 -08:00
Tom
b8dc985279 fix: visual glitches on landscape navbar (#429) 2026-02-09 20:00:03 -08:00
Jaime García
090701b92b chore(i18n): Update Spanish translation (#427) 2026-02-09 19:59:39 -08:00
Jaime García
7767a66fb8 fix: Use Bluetooth tethering connection (#428) 2026-02-09 19:59:20 -08:00
eddyizm
d1122bef4e fix: updated album art provider from hardcoded to build config id 2026-02-09 17:49:30 -08:00
eddyizm
72d4495582 fix: added dynamic application id from gradle variant (#425) 2026-02-08 21:23:35 -08:00
eddyizm
499644d041 fix: bungled the last release 2026-02-08 16:34:14 -08:00
eddyizm
21ed78d959 chore: bumping version, fastlane and changelog 2026-02-08 16:14:22 -08:00
Tom
5ad99b9f27 feat: increase items per row on landscape view (#411)
* feat: increase items per row on landscape view

This covers the catalogues: artist, album and genre; also the list of albums on artist view.
This was implemented by adierebel/tempo fork, I only cherry-picked some commits.

Co-authored-by: adierebel <adie.rebel@gmail.com>

* feat: add landscape layout to song listing views

This includes the playlist page and the album page.

* fix: bad scaling on small screens

This rollbacks to the original code by adierebel/tempo fork

* fix: remove hardcoded height blocking scroll

This was addressed in 989ca35, forgot to fix it here as well

* fix: wrap content height rather than inheriting it from parent

* feat: add ui of choice selector in setting for items per row

* feat: link getter to landscapes items per row setting an implement it

* fix: wrong default value

Co-authored-by: eddyizm <wtfisup@hotmail.com>

* feat: add default value on setting string

To introduce the new feature of landscape layouts.

Co-authored-by: eddyizm <wtfisup@hotmail.com>

---------

Co-authored-by: adierebel <adie.rebel@gmail.com>
Co-authored-by: eddyizm <wtfisup@hotmail.com>
Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-02-08 15:20:53 -08:00
T R
3de5390140 fix: album art now displays on android auto (#414)
Co-authored-by: Thomas R <tdr@thomasr.co>
Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-02-08 10:34:44 -08:00
eddyizm
d215581e19 fix: keep observer until data is received on continuous play (#421) 2026-02-08 10:18:36 -08:00
tiltshiftfocus
54612c6b74 patch: Addressing some UI/UX quirks (#413)
* beautify lyrics display

* use dialog to select playback speed

to prevent accidental clicks
2026-02-08 10:18:01 -08:00
eddyizm
eaac728a26 chore: bump version and change logs 2026-02-02 20:25:40 -08:00
skajmer
65d2f8e33f chore(i18n): Update Polish translation (#402)
* Add #338

* Add #3700 (strings.xml)

* Add #370 (arrays.xml)

* Add #386

* Add #394
2026-02-02 07:09:29 -08:00
Tom
baf4e0f0fc chore(i18n): set links as untranslatable (#400) 2026-01-31 17:37:41 -08:00
Tom
26c7bee106 feat: Add selector for playlist visibility (#394)
* feat: add selector for playlist visiblity when adding a song

* fix: wrong number of arguments

* feat: make dialog text localized

* chore: add es, fr, it, pt localization for playlist visibility dialog

---------

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-01-31 17:10:58 -08:00
Pascal Grittmann
6e51611867 Improve Synced Lyrics (#384)
* feature: click on synced lyrics to navigate in song

* only update lyrics if needed

improves performance and allows user to scroll synced lyrics

* fix: don't scroll to start after end of song
2026-01-31 08:16:13 -08:00
eddyizm
d67e432731 chore: added playlist strings for pr #394 2026-01-31 08:10:30 -08:00
Pascal Grittmann
8b61396b0f Fix missing Replay Gain metadata from .m4a files (#396)
fix missing replay gain metadata from .m4a files
2026-01-29 20:22:09 -08:00
eddyizm
0fb6e55b12 chore: update changelog and fastlane 2026-01-26 21:32:07 -08:00
eddyizm
dd7aa2291b chore: bump version 2026-01-26 21:29:40 -08:00
eddyizm
ec33c32c89 fix: updated dialog import to address crashing on android 15 (#392)
resolves #362
2026-01-26 21:25:27 -08:00
Jaime García
e0ad4e3701 fix: Avoid crash when server has no songs (#389) 2026-01-26 16:24:23 -08:00
eddyizm
253f8033c5 Merge branch 'development' 2026-01-25 11:41:50 -08:00
eddyizm
c1aed1a4c1 chore: version/changelog/fastlane bumps 2026-01-25 11:41:26 -08:00
eddyizm
23f58439ba Merge branch 'development' 2026-01-25 11:34:27 -08:00
eddyizm
4c99ced597 chore: version/changelog/fastlane bumps 2026-01-25 11:34:16 -08:00
eddyizm
8d215a7f1c feat: add configurable timeout (#386) 2026-01-25 11:27:12 -08:00
eddyizm
38fc4a0936 chore: forget to check in fastlane change log 2026-01-25 08:06:29 -08:00
eddyizm
d9949349da chore: forget to check in fastlane change log 2026-01-25 08:06:06 -08:00
Jaime García
877d29d285 chore(i18n): Update Spanish translation (#381) 2026-01-24 14:05:52 -08:00
Jaime García
9a17aa8b98 fix: Proper raw stream detection (#382) 2026-01-24 14:05:36 -08:00
eddyizm
fd41395ab8 chore: bump version for tag 2026-01-24 09:11:41 -08:00
eddyizm
269066e036 Merge branch 'development' 2026-01-24 09:09:23 -08:00
eddyizm
488460ea9d chore: bump version/changelog 2026-01-24 09:07:26 -08:00
Pascal Grittmann
d16a9c234f feat: Playback speed controls for music (#376)
Enable playback speed controls for music

Button is moved to the top left, next to bit rate, because it would
overlap with the "shuffle" button.
The speed rotation logic was cleaned up to 0.25x increments without all
the hard-coded constants and code duplication.

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-01-24 08:39:57 -08:00
eddyizm
07b507691c chore: fixed read me donate link 2026-01-24 08:33:34 -08:00
drakeerv
bde34d3df0 feat: Implement duration and seeking for transcodes (#358)
Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-01-24 08:28:47 -08:00
Pascal Grittmann
e5b7756f96 fix: Check for OpenSubsonic extensions also with password authentication (#375)
check for OpenSubsonic extensions also with pwd auth

Co-authored-by: eddyizm <eddyizm@gmail.com>
2026-01-24 08:28:20 -08:00
eddyizm
04e692e5e9 chore: fixed read me donate link 2026-01-24 07:51:30 -08:00
skajmer
a23a663d32 chore(i18n): Update Polish translation (#374)
* Add #338

* Add #3700 (strings.xml)

* Add #370 (arrays.xml)
2026-01-23 22:15:51 -08:00
eddyizm
023bd8071a fix: use existing future when adding tracks, dialed random album trac… (#373)
fix: use existing future when adding tracks, dialed random album tracks back to 100, vs 1000.
2026-01-22 22:09:49 -08:00
eddyizm
72b1517f61 chore: added openapk badge, contributor shoutout, another link fix 2026-01-22 22:08:52 -08:00
eddyizm
e62ea72c2f merge-conflit 2026-01-21 07:54:51 -08:00
eddyizm
a24ccf2556 chore: added attribute 2026-01-21 07:53:08 -08:00
eddyizm
49838e2e0f chore: another attempt at fixing the links 2026-01-21 07:46:02 -08:00
eddyizm
8ed1248ee1 docs: fixed main links for github page 2026-01-21 07:21:24 -08:00
eddyizm
e9d54957ae chore: pending release notes update and href links on readme 2026-01-20 21:59:48 -08:00
eddyizm
3cd5843c4b feat: sort preference for playlists (#370) 2026-01-18 16:39:28 -08:00
eddyizm
75513d3bd4 fix: sort playlist catalog view (#368)
fix: sorts playlist catalog view
2026-01-18 08:54:25 -08:00
eddyizm
c0c84269ef fix: toast for made for you click indication (#365) 2026-01-17 18:21:14 -08:00
eddyizm
fa2e029f9f Merge branch 'main' into development 2026-01-17 18:18:32 -08:00
eddyizm
f1bfb095b7 chore: updated readme and added known issues for airsonic work around (#366) 2026-01-17 18:17:23 -08:00
Jaime García
4328415efc chore(i18n): Update Spanish translation (#364) 2026-01-17 08:43:24 -08:00
Benoît Smith
092ae14ea2 chore: French localization update (#356)
* Add toast message for no artist info

* Add French strings for instant mix generation messages

* Add French string for music download directory

* Add neutral button string for download storage dialog

* Add French strings for download refresh features

* Add French translations for heart controls and loading

* Update French strings for starred albums and artists

* Add album count string to French resources

* Add French translations for player lyrics features

* Update French strings for pluralization and playlist

* Fix French translation for podcast info title

* Add and update French radio station strings

* Add settings for playlist duplicates in French strings

* Add download folder settings in French strings

* Add download folder settings and update equalizer summary

* Add support discussion and update strings in French

* Update French strings for UI elements

Updated French translations for heart control descriptions, mobile bitrate settings, queue syncing title, and added mini shuffle button settings.

* Update French strings for settings and lyrics

* Update French strings for offline sync settings

* Add playlist string to French resources

* Add French translations for asset links

* Revise French subtitles for starred artists and albums

Updated subtitles for starred artists and albums in French localization.

* Add French strings for widget and settings
2026-01-14 21:44:36 -08:00
eddyizm
26af8a692f Merge branch 'main' into development 2026-01-14 21:27:18 -08:00
eddyizm
b870f4c866 fix: added country code to catalan 2026-01-14 21:27:14 -08:00
DevMatei
cf4e78eafc i18n: Add Romanian translation (including locale_config this time!) (#357)
* i18n: Add Romanian (ro) translation

* feat(i18n): add Romanian (ro) locale support

* fix: added Country code

---------

Co-authored-by: eddyizm <wtfisup@hotmail.com>
2026-01-14 21:25:45 -08:00
eddyizm
83e23c44d9 chore: updated instant mix verbage 2026-01-14 21:17:52 -08:00
eddyizm
c0959c7ca4 chore: updated change log and build version 2026-01-13 20:11:13 -08:00
eddyizm
e77f3bf9b3 fix: instant mix random songs (#354)
* wip: updated instant mix request size

* Address broken continuous play 

* wip: filling queue, getting dupes

* fix: deduped the song track list
2026-01-13 20:00:46 -08:00
DevMatei
55265615e6 feat (i18n): Add Romanian (ro) translation (#349) 2026-01-11 09:19:53 -08:00
eddyizm
bd872fc23d chore: bumped version and updated changelog 2026-01-10 07:52:19 -08:00
eddyizm
64a1966ad8 Bug/instant mix issues (#344)
* fix: song bottom sheet changed to livedata and fixed issue

* fix: refactor bottom sheet instant mix calls to use livedata.
2026-01-09 18:44:59 -08:00
eddyizm
5ef5731fe3 feat: Ability to toggle visibility of artist biography (#338) 2026-01-08 21:25:54 -08:00
kmarius
c5cece8477 Hide biography section when no info is available 2026-01-07 18:14:53 +01:00
kmarius
bae9221070 feat: Ability to toggle artist biography 2026-01-07 18:14:51 +01:00
eddyizm
c0dbe01bf9 chore: updated read me and pending release info 2026-01-06 21:05:24 -08:00
eddyizm
5f550b0df4 chore(i18n): Update Polish translation (#339) 2026-01-05 07:33:48 -08:00
skajmer
6100c3e7f1 Add #330 2026-01-05 14:59:19 +01:00
skajmer
f01ca9fed0 Merge branch 'eddyizm:development' into development 2026-01-05 14:56:33 +01:00
eddyizm
d232ebfa6f feat(i18n): add missing keys, update Chinese translation and alphabetize (#332) 2026-01-04 15:41:48 -08:00
eddyizm
53ca88989f Merge branch 'development' into improve/update-zh 2026-01-04 15:41:31 -08:00
eddyizm
a82cf70433 Bug/instant mix issue (#330) 2026-01-04 11:57:20 -08:00
eddyizm
89aa18b5f0 chore: Clarify Android Auto enablement (#336) 2026-01-04 11:56:56 -08:00
eddyizm
431014adc4 fix: updated song bottom sheet to match album/artist bottom sheets 2026-01-04 11:31:53 -08:00
eddyizm
6110a9c8e7 fix: added a timeout for the callbacks to dismiss dialog and notify the user 2026-01-04 10:05:16 -08:00
eddyizm
993374e56c fix: adde a scheduled delay to allow callbacks to succeed 2026-01-04 09:27:53 -08:00
eddyizm
a2801f3168 fix: reduced debounce, added toast 2026-01-04 07:53:07 -08:00
eddyizm
99c31f4318 wip: bumps debounce time 2026-01-03 08:18:29 -08:00
eddyizm
05785979e3 fix: cleans up duplicates 2026-01-03 08:17:53 -08:00
Age Bosma
586a1a160e Clarify Android Auto enablement 2026-01-03 15:53:37 +01:00
eddyizm
d04ed8d430 fix: address duplicate track bug, wrong order in queue, and updated album instant mix 2026-01-01 11:50:48 -08:00
eddyizm
193447d07e fix: used set to address duplicates and removed toast that was firing to early 2025-12-31 08:20:03 -08:00
hongwei
1725b0de2e feat(i18n): add missing keys, update Chinese translation and alphabetize 2025-12-31 14:06:57 +08:00
eddyizm
a2401302ed wip: beta build 2025-12-30 21:46:07 -08:00
eddyizm
f39891dd2c wip: added logging to media manager to track down bug in bottom sheet dialogs 2025-12-29 16:37:20 -08:00
eddyizm
8c5390bfef wip: more instant mix refactor to be able to accumulate tracks 2025-12-29 16:36:42 -08:00
eddyizm
10673a49d4 chore: bump version, bringing in dev changes to test 2025-12-28 08:20:58 -08:00
eddyizm
3ce34fb874 Merge branch 'development' into bug/instant-mix-issue 2025-12-28 08:19:38 -08:00
eddyizm
5c94e9122c chore: bumped version 2025-12-28 08:12:48 -08:00
eddyizm
8140e80d61 wip: artist logic squared away, seems to be working as expected, mostly. still need more testing 2025-12-27 19:05:14 -08:00
eddyizm
c1b2ec09a4 wip: radio working from artist page 2025-12-27 17:52:29 -08:00
eddyizm
3b3f55c5de wip: point artist repo instant mix to song repo 2025-12-27 17:50:53 -08:00
eddyizm
17020e5192 wip: album tracks working, album bottom sheet only pulling in the album, not quite right 2025-12-27 17:48:06 -08:00
eddyizm
f22aea7b1d wip: changed seedtype constant to camelcase, updated references 2025-12-27 17:46:16 -08:00
eddyizm
844b57054b wip: updated album repo for song instant mix type update 2025-12-27 12:31:01 -08:00
eddyizm
8de9aff1f6 wip: refactor song repo instant mix to take in a type 2025-12-27 12:27:18 -08:00
eddyizm
f59f572e5c wip: added queue type to for instant mix calls 2025-12-27 11:04:43 -08:00
skajmer
da2221540e Update strings.xml 2025-12-27 19:23:51 +01:00
skajmer
9fa29c183a Add #328 2025-12-27 19:21:57 +01:00
eddyizm
d034171d92 chore: formatting 2025-12-27 08:17:16 -08:00
eddyizm
3a30b3d379 feat: finishing up album bottom sheet dialog updates for instant mix refactor 2025-12-26 22:12:53 -08:00
eddyizm
2624f396e5 wip: refactor album repo to use the song repo instant mix 2025-12-26 22:08:07 -08:00
eddyizm
8ae32a3a22 fix: give user feedback when trying to add podcast/radio on unsupport… (#328) 2025-12-26 19:20:29 -08:00
eddyizm
3c1975f6bf wip: initial refactor of instant mix in to be used everywhere else 2025-12-26 17:03:41 -08:00
eddyizm
43a96faca4 fix: give user feedback when trying to add podcast/radio on unsupported back servers 2025-12-25 14:03:30 -08:00
eddyizm
bbd6d0864c chore: added airsonic to docs, comments in home tab xml 2025-12-24 07:16:16 -08:00
eddyizm
ccea7674bd chore: bumped version, added fastlane metadata 2025-12-22 19:14:29 -08:00
eddyizm
7f332c26ad chore: changeup bump for version 2025-12-22 19:10:10 -08:00
eddyizm
206a7f38ca chore: updated changelog 2025-12-22 18:39:40 -08:00
eddyizm
16e0a5e12e feat: added regular playlist to home view (#322) 2025-12-22 18:36:56 -08:00
eddyizm
c6896939e2 fix: serialized corrected mapping for playlist cover art to appear 2025-12-22 18:30:49 -08:00
eddyizm
526253723b feat: added regular playlist to home view 2025-12-22 11:04:25 -08:00
eddyizm
9350a9cc2e chore: updating pending release info 2025-12-21 08:20:50 -08:00
eddyizm
e2ec2e4602 Update description_empty_title in French and Spanish (#315) 2025-12-20 10:06:27 -08:00
eddyizm
bca2e8fcae Merge branch 'development' into main 2025-12-20 10:06:03 -08:00
pochopsp
43674ea1f9 Update description_empty_title in French and Spanish 2025-12-20 18:40:55 +01:00
eddyizm
373a1f87a1 Update description_empty_title in Italian (#314) 2025-12-20 07:56:43 -08:00
eddyizm
e14a595fba Merge branch 'development' into patch-1 2025-12-20 07:56:19 -08:00
pochopsp
727e137008 Update description_empty_title in Italian 2025-12-20 12:07:00 +01:00
eddyizm
883d853129 fix: checks preference and writes files externally, updates the ui (#312) 2025-12-17 22:28:20 -08:00
eddyizm
0d329aff64 fix: checks preferecen and writes files externally, updates the ui 2025-12-17 22:27:00 -08:00
eddyizm
94cb6fa279 chore(i18n): Update Polish translation (#310) 2025-12-17 21:25:14 -08:00
eddyizm
257d80ecac Merge branch 'development' into development 2025-12-17 21:25:02 -08:00
eddyizm
d0f77fe0fc Update description_empty_title in English and Polish (#307)
resolves #306
2025-12-17 21:23:11 -08:00
skajmer
e95b504dbb Merge branch 'eddyizm:development' into development 2025-12-17 12:11:19 +01:00
Tymon Flower
0b68799507 Update description_empty_title in English and Polish 2025-12-15 18:25:28 +01:00
eddyizm
9167be2cf2 chore: added new version details 2025-12-12 20:52:05 -08:00
eddyizm
d426c08cdd chore: version bump 2025-12-12 20:45:42 -08:00
eddyizm
972c32b9d8 chore: updated change log, fastlane for pending release 2025-12-12 20:14:10 -08:00
eddyizm
a279e20a49 feat: add heart to artist/album pages, fixed artist cover art failing (#303) 2025-12-12 07:15:11 -08:00
eddyizm
fe60fea928 feat: add heart to artist/album pages, fixed artist cover art failing 2025-12-11 22:07:44 -08:00
skajmer
c6df43da9c left some english in by accident 2025-12-10 22:01:19 +01:00
skajmer
475ed3e7c8 Add #300 2025-12-10 21:59:32 +01:00
skajmer
fb4c762655 Merge branch 'eddyizm:development' into development 2025-12-10 21:56:49 +01:00
eddyizm
a110faabe3 feat: integrate sort recent searches chronologically (#300) 2025-12-08 20:43:15 -08:00
j4mm3ris
df2bf43492 Default the search sort setting to former sorting behavior. 2025-12-07 21:39:10 +02:00
j4mm3ris
b46fea6890 Fix indentation according to previous versions 2025-12-07 21:30:21 +02:00
skajmer
213a0d5293 Add #298 2025-12-07 20:18:40 +01:00
eddyizm
08b6379601 chore: updated readme 2025-12-07 10:48:58 -08:00
eddyizm
3fbadc2521 fix: handle empty albums and null mappings (#301) 2025-12-07 10:05:30 -08:00
eddyizm
9e78caeda4 fix: updates to starred syncing to user defined directory (#298) 2025-12-07 10:05:13 -08:00
eddyizm
e072a49288 fix: handle empty albums and null mappings 2025-12-07 10:04:05 -08:00
j4mm3ris
b89e18eebf feat: integrate sort recent searches chronologically 2025-12-07 13:24:03 +02:00
eddyizm
63607794d6 fix: updates to starred syncing to user defined directory 2025-12-02 21:46:04 -08:00
eddyizm
37842fd897 chore: bumped verison 2025-11-30 17:51:22 -08:00
eddyizm
a1397a224b chore: adding pending release and fastlane updates 2025-11-30 16:43:12 -08:00
eddyizm
804d6af6c3 chore(i18n): Update Polish translation (#291) 2025-11-30 13:49:15 -08:00
skajmer
e315169005 Add #288 2025-11-30 19:57:56 +01:00
skajmer
ea76afee09 Merge branch 'eddyizm:development' into development 2025-11-30 19:52:39 +01:00
eddyizm
45dda3af9b Feat/playerqueue fab (#288) 2025-11-30 10:25:11 -08:00
eddyizm
3d70b51244 fix: updated order of buttons 2025-11-30 09:10:02 -08:00
eddyizm
22f196c8c0 fix: refactor start queue to put the db writing in the background (#287) 2025-11-28 10:00:35 -08:00
eddyizm
540aa9ba73 feat: implemented download queue fab 2025-11-28 09:57:29 -08:00
eddyizm
1ff0b83a19 feat: implemented load queue, adding logging 2025-11-27 13:28:07 -08:00
eddyizm
27f5a47cc9 feat: save q to playlist, removed save queue button, added style to fab. 2025-11-27 08:04:40 -08:00
eddyizm
732b6ad09d fix: moved existing functionality to fab buttons, removed queue text/button from top 2025-11-25 15:48:48 -08:00
eddyizm
0df7346a14 Merge branch 'development' into feat/playerqueue-FAB 2025-11-24 20:52:20 -08:00
eddyizm
786697109d chore: bringing in media service refactor for more testing (#286) 2025-11-24 20:49:48 -08:00
eddyizm
1bfadb0669 fix: refactor start queue to put the db writing in the background 2025-11-24 20:46:46 -08:00
eddyizm
79dc1cc93b chore: bringing in media service refactor for more testing 2025-11-24 13:11:29 -08:00
eddyizm
38fb2c69f1 wip: added fab, need to implement actions 2025-11-24 11:36:56 -08:00
skajmer
b34f827bc0 Add #276 2025-11-23 20:29:20 +01:00
eddyizm
97d1b408e1 chore: bumped version, updated change logs 2025-11-23 09:58:04 -08:00
eddyizm
a5065578ca Fix/start queue blocking UI (#283) 2025-11-23 09:36:31 -08:00
eddyizm
aac5c6067d Merge branch 'library-play' into fix/start-queue-blocking-ui 2025-11-23 09:34:39 -08:00
eddyizm
cfd7cf314b feat: add play functionality to library folder/index items (#276) 2025-11-23 09:33:44 -08:00
eddyizm
c4b73f6014 Merge branch 'development' into main 2025-11-23 09:33:26 -08:00
eddyizm
35d377ce31 Revert "refactor MediaService" (#282) 2025-11-23 09:30:34 -08:00
eddyizm
5e330ac451 Revert "refactor MediaService"
This reverts commit 7aa325f914.
2025-11-23 09:18:32 -08:00
eddyizm
8188ef169c fix: put queue into background thread 2025-11-23 09:09:20 -08:00
eddyizm
3496918ce6 Merge branch 'development' into library-play 2025-11-22 14:30:20 -08:00
eddyizm
c72f368f6a Add Obtainium badge to README (#280) 2025-11-22 11:52:04 -08:00
Mikael Dúi Bolinder
eb089847e0 Add Obtainium badge to README 2025-11-22 21:29:04 +02:00
eddyizm
8aaa6b207e chore: added reproducible build badge 2025-11-22 09:30:01 -08:00
eddyizm
72d560e4eb chore: updated changelog, bumped version for release 2025-11-22 09:22:13 -08:00
eddyizm
31219ea754 fix: it strings needed an single quote escape 2025-11-22 09:07:49 -08:00
eddyizm
52c411ead0 chore(i18n): Update Italian translation (#278) 2025-11-22 08:36:21 -08:00
eddyizm
0edbd15d47 chore(i18n): Update Spanish translation (#272) 2025-11-22 08:34:17 -08:00
eddyizm
342241963a Refactor MediaService (#267) 2025-11-22 08:33:07 -08:00
bunz
f5b381eb35 chore(i18n): Update Italian translation 2025-11-21 13:36:44 +01:00
Ante Budimir
be33401b6f feat: add play functionality to library folder/index items
- add play button to inner folders in library
- implement recursive song collection from folders and subfolders
- filter out video files, play only audio tracks
- add user feedback with toast notifications
2025-11-20 19:13:46 +02:00
eddyizm
c415db0cc5 chore: pending release update 2025-11-16 09:35:53 -08:00
eddyizm
35576c3d6f feat: Add Catalan i18n (#268) 2025-11-16 09:23:06 -08:00
Jaime García
a11fbfa829 chore(i18n): Update Spanish translation 2025-11-16 17:50:45 +01:00
Jaime García
26d1b144e4 chore(i18n): Update Spanish translation 2025-11-16 17:48:51 +01:00
pca006132
16b63bf13c Merge branch 'development' into refactor-mediaservice 2025-11-16 20:33:48 +08:00
Marc Riera
52434f3aa9 feat: Add Catalan i18n 2025-11-16 10:36:14 +01:00
pca006132
7aa325f914 refactor MediaService 2025-11-16 17:22:22 +08:00
pca006132
5a8a631449 fix shuffle 2025-11-16 14:19:58 +08:00
eddyizm
e6bbd7b2bf Fix player queue soft-lock (#266) 2025-11-15 22:11:37 -08:00
observer
3721484dff Fix player queue soft-lock 2025-11-16 00:13:36 +00:00
eddyizm
6698052ba5 chore: updated change log 2025-11-15 10:34:38 -08:00
eddyizm
f6f24acfdf chore: bumped version 2025-11-15 09:31:52 -08:00
eddyizm
ca8bcba0d7 fix: add podcast channel visible when empty podcasts (#260) 2025-11-14 21:55:13 -08:00
eddyizm
9a36f8541f chore: added tested backends to docs as well as podcast/radio feed screenshots 2025-11-14 21:47:46 -08:00
eddyizm
0026dc287f Update Polish translation (#257) 2025-11-14 06:16:30 -08:00
skajmer
c65077172d Update strings.xml 2025-11-14 13:38:19 +01:00
skajmer
6e6c261f35 Update strings.xml 2025-11-14 13:35:05 +01:00
skajmer
130cbbd7dd Missing strings
sort albums by count stuff I missed
2025-11-14 13:28:21 +01:00
eddyizm
63668f5a8c fix: made sure the empty graphic was there when list was empty 2025-11-13 21:37:48 -08:00
eddyizm
193b551773 fix: home radio add station missing from view 2025-11-13 19:07:45 -08:00
eddyizm
76a0e12222 fix: add podcast channel visible when empty podcasts 2025-11-13 16:07:10 -08:00
skajmer
887d8c85ee Accidental typo 2025-11-13 10:38:03 +01:00
skajmer
6a90f06084 Added #253 2025-11-13 10:34:52 +01:00
skajmer
f7a21cbb52 Merge branch 'eddyizm:development' into development 2025-11-13 10:23:43 +01:00
eddyizm
3c6c240b9d chore: more pending release updates 2025-11-12 21:53:35 -08:00
eddyizm
2553c06a9f Fixed crash when viewing share (#255) 2025-11-12 07:01:43 -08:00
eddyizm
62a10d142e fix:github release check (#253) 2025-11-12 06:59:05 -08:00
drakeerv
955dc1b015 Fixed crash when viewing share 2025-11-12 09:45:07 -05:00
eddyizm
6124ec66f3 feat: added setting to disable github check and completely disable/hide when using the degoogled version 2025-11-11 18:06:01 -08:00
eddyizm
2c6287405e fix:github release check 2025-11-11 12:00:05 -08:00
eddyizm
0be309fb22 chore: updated pending release notes 2025-11-11 11:12:54 -08:00
eddyizm
a79543569d fix: disallow duplicate songs in queue (#252) 2025-11-11 11:08:26 -08:00
eddyizm
e3d7120193 chore: Update russian lang strings.xml (#249) 2025-11-11 11:08:02 -08:00
eddyizm
80a3a54476 Merge branch 'development' into patch-1 2025-11-11 11:05:25 -08:00
eddyizm
6448cc598d fix: disallow duplicate songs in queue 2025-11-10 10:53:16 -08:00
skajmer
c4bd30d512 Merge branch 'eddyizm:development' into development 2025-11-10 11:41:33 +01:00
eddyizm
b2e3596d87 chore: updated change log 2025-11-09 17:07:07 -08:00
eddyizm
ccfe74a6ea chore: bump version for release 2025-11-09 16:49:17 -08:00
eddyizm
f4ffdc985e chore: updating pending release and typo in settings 2025-11-09 16:48:16 -08:00
eddyizm
33981f9885 Fix shuffling genres only queuing 25 songs (#246) 2025-11-09 16:41:17 -08:00
observer
1bc93cce0e use overloads instead of new methods 2025-11-09 22:01:33 +00:00
eddyizm
ec0eee9d3f implement scroll to currently playing feature (#247) 2025-11-09 13:49:29 -08:00
observer
140546ca4d log in catch statement in PlayerQueueFragment:onResume 2025-11-09 21:27:17 +00:00
eddyizm
fd075a02c5 chore: updating change log and pending changes 2025-11-09 09:33:23 -08:00
observer
4d1d953a3a implement scroll to currently playing feature 2025-11-09 16:03:02 +00:00
eddyizm
9dd509be22 feat: Make artist and album clickable (#243) 2025-11-09 07:06:41 -08:00
observer
efaae35976 uncap mediamanager queue previously at 25 at 500 2025-11-09 15:05:49 +00:00
observer
35784216bc use getRandomSampleWithGenre() rather than getSongsByGenre() 2025-11-09 15:05:05 +00:00
observer
8ce0a82506 getRandomSongs2(): use new subsonic getRandomSongs param 'genre' instead of getSongsByGenre 2025-11-09 15:02:54 +00:00
skajmer
51883cd82b Merge branch 'eddyizm:development' into development 2025-11-09 13:06:27 +01:00
eddyizm
829c5d85f6 fix: Images not filling holder (#244) 2025-11-08 19:31:36 -08:00
eddyizm
748a19ef44 fix: discovery image fills holder at start 2025-11-08 09:34:25 -08:00
eddyizm
e987226954 feat: makes discovery media item clickable on home page #53 2025-11-08 09:09:09 -08:00
Max
59e2f4a7fa Update strings.xml
Adding new translation strings
2025-11-07 15:04:56 +03:00
eddyizm
817b53efaa fix: Equalizer fix in main build variant (#239) 2025-11-06 17:22:56 -08:00
Jaime García
12c7ec86a9 fix: Add listener to enable equalizer when audioSessionId changes (main build variant) 2025-11-07 02:06:08 +01:00
eddyizm
611b5001be chore: bumped version for new tag 2025-11-06 16:08:55 -08:00
eddyizm
923cfd5bc9 fix: equalizer missing referenced value 2025-11-06 16:08:07 -08:00
eddyizm
36d2320e70 chore: bump version and changelog for release 2025-11-06 15:44:46 -08:00
eddyizm
17713ee400 fix: Add listener to enable equalizer when audioSessionId changes (#235) 2025-11-06 15:40:39 -08:00
eddyizm
ee3465868e Album track list bug (#237) 2025-11-06 15:40:14 -08:00
eddyizm
9e8141a8d9 chore: name version 2025-11-06 14:49:19 -08:00
eddyizm
043e1b39b0 Revert "fix: do not override itemType and itemId"
This reverts commit d35146dba3.
2025-11-06 14:35:29 -08:00
Jaime García
938c1de906 chore: Remove comment 2025-11-06 04:48:24 +01:00
Jaime García
a0dfe63660 fix: Add listener to enable equalizer when audioSessionId changes 2025-11-06 03:18:39 +01:00
skajmer
42b7441467 Merge branch 'eddyizm:development' into development 2025-11-05 22:20:18 +01:00
eddyizm
28c2f87b26 chore: updated change log 2025-11-05 09:15:11 -08:00
eddyizm
9ab22bfede chore: bump version 2025-11-05 07:51:55 -08:00
eddyizm
20900fb557 chore: pre release prep 2025-11-04 22:11:02 -08:00
eddyizm
7457c5b6e3 fix: skip mapping downloaded item (#228) 2025-11-04 15:43:04 -08:00
pca006132
e5a928ec0f fix: skip mapping downloaded item 2025-11-05 00:35:27 +08:00
eddyizm
147c8360a6 Revert "improve battery consumption" (#227) 2025-11-04 07:02:50 -08:00
eddyizm
d5d504fc64 Revert "improve battery consumption" 2025-11-04 07:02:19 -08:00
eddyizm
24eead2d0a improve battery consumption (#223) 2025-11-03 21:20:06 -08:00
pca006132
2644fa52b6 enable offload 2025-11-03 22:33:42 +08:00
pca006132
38c144c073 avoid rebuffering after track change 2025-11-03 22:33:21 +08:00
pca006132
1dca1ef68d avoid updating player bottom sheet when not visible 2025-11-03 16:16:06 +08:00
pca006132
ba94d7e5cc fix null 2025-11-03 16:01:15 +08:00
pca006132
0028872e3f update one media item only 2025-11-03 15:07:29 +08:00
pca006132
be9eec625a avoid full updates 2025-11-03 14:50:06 +08:00
pca006132
b335ddec01 cache artwork bitmap 2025-11-03 13:33:22 +08:00
skajmer
8ad35ce83a Merge branch 'eddyizm:development' into development 2025-11-02 18:15:01 +01:00
eddyizm
eb5c4721d1 fix: update MediaItems after network change (#222) 2025-11-02 08:01:43 -08:00
eddyizm
b0e8fa75ca chore: update media3 dependencies (#217) 2025-11-02 08:00:55 -08:00
eddyizm
27d7288ee9 fix: do not override getItemViewType and getItemId (#221) 2025-11-02 08:00:37 -08:00
eddyizm
287921de09 fix: playlist page should not snap (#218) 2025-11-02 07:59:51 -08:00
eddyizm
e5cb8793b0 fix: remove NestedScrollViews for fragment_album_page (#216) 2025-11-02 07:59:34 -08:00
eddyizm
f25e7f250a Fix downloaded tab performance (#210) 2025-11-02 07:59:10 -08:00
eddyizm
911acc3c2d feat: sort artists by album count (#206) 2025-11-02 07:58:46 -08:00
pca006132
4b7f60bb8c fix: update MediaItems after network change 2025-11-02 16:44:15 +08:00
pca006132
d35146dba3 fix: do not override itemType and itemId 2025-11-02 14:54:32 +08:00
eddyizm
6c3897a400 Update USAGE.md with instant mix details (#220) 2025-11-01 16:50:47 -07:00
Thomas Anderson
1002499d92 Update USAGE.md with instant mix details
Follow up of https://github.com/eddyizm/tempus/issues/184#issuecomment-3475333928
2025-11-01 22:43:09 +03:00
skajmer
4464b5b34d Merge branch 'eddyizm:development' into development 2025-11-01 09:52:55 +01:00
pca006132
77c0b86dac fix: playlist page should not snap 2025-11-01 13:34:47 +08:00
pca006132
0abdfc6b19 fix: remove NestedScrollViews for fragment_album_page 2025-11-01 13:31:05 +08:00
pca006132
52b2ca8fa7 chore: update media3 dependencies 2025-11-01 11:44:40 +08:00
pca006132
7f66124614 fix: download tab performance 2025-10-31 20:16:01 +08:00
eddyizm
9930537486 shuffle for artists without using getTopSongs (#207) 2025-10-30 18:54:19 -07:00
pca006132
5d51132921 feat: add preference to sort artists by album count 2025-10-30 20:01:47 +08:00
pca006132
4b2e963a81 fix: shuffle for artists without using getTopSongs 2025-10-30 20:01:28 +08:00
pca006132
4c865e199d feat: sort artists by album count 2025-10-30 18:01:07 +08:00
eddyizm
fc58869354 chore(i18n): Update Spanish (es-ES) translation (#205) 2025-10-29 17:47:03 -07:00
Jaime García
5e1a2b41e9 chore(i18n): Update Spanish (es-ES) translation 2025-10-29 22:06:13 +01:00
eddyizm
77bdd71d79 chore: updated bug issue 2025-10-29 09:29:36 -07:00
skajmer
7267c13ee0 Add #199 2025-10-29 12:38:15 +01:00
eddyizm
4ab1f034d8 chore: bumped version for release, added changelogs 2025-10-28 18:53:45 -07:00
eddyizm
4bd8bbfa4c chore: removed build badge since its not live yet 2025-10-28 18:39:41 -07:00
eddyizm
3fc03114e2 chore: updated github url 2025-10-28 18:37:45 -07:00
eddyizm
576c93e6cb Crash on share no expiration date or field returned from api (#199) 2025-10-27 20:57:36 -07:00
eddyizm
ac674d937a fix: handle null or no expiry field being sent back from server 2025-10-27 20:47:46 -07:00
eddyizm
0ed329022e fix: gradle updated to exclude offending blobs, change log link fixed, removed old screenshots. 2025-10-27 19:55:29 -07:00
eddyizm
b8b4a77349 chore: updated tempo references to tempus including github check (#197) 2025-10-27 09:08:13 -07:00
eddyizm
de14663b25 chore: updated tempo references to tempus including github check 2025-10-27 09:06:57 -07:00
eddyizm
747af0d81c fix: updated crash report and degoogled icon for izzydroid #195 2025-10-27 07:39:07 -07:00
eddyizm
c95e7cc5e0 fix: disabled workflow, manual build 2025-10-26 12:01:51 -07:00
eddyizm
0c3b43c5dc fix: build paths 2025-10-26 11:49:08 -07:00
eddyizm
830e9076f1 never surrender 2025-10-26 11:32:23 -07:00
eddyizm
cd9ae97bc7 fix: bumped version, update path from build error logs 2025-10-26 11:17:59 -07:00
326 changed files with 17179 additions and 4495 deletions

View File

@@ -26,6 +26,7 @@ Outline the steps required to reproduce the bug, including any specific actions,
- Android device: [Device Model] - Android device: [Device Model]
- Android OS version: [Android Version] - Android OS version: [Android Version]
- App version: [App Version] - App version: [App Version]
- App variant: [goole play services, degoogled]
- Other relevant details: [e.g., specific network conditions, external dependencies] - Other relevant details: [e.g., specific network conditions, external dependencies]
## Logs or Screenshots ## Logs or Screenshots

View File

@@ -23,10 +23,11 @@ Please provide the steps to reproduce the crash:
- Android device: [Device Model] - Android device: [Device Model]
- Android OS version: [Android Version] - Android OS version: [Android Version]
- App version: [App Version] - App version: [App Version]
- App variant: [goole play services, degoogled]
- Other relevant details: [e.g., specific network conditions, external dependencies] - Other relevant details: [e.g., specific network conditions, external dependencies]
## Crash Logs/Stack trace ## Crash Logs/Stack trace
<!-- If available, please provide the crash log or stack trace related to the crash. Include it inside a code block (surround with triple backticks ```). Please use the unsigned apk (app-tempo-debug.apk), as the logs would be illegible and therefore useless for this purpose. --> <!-- If available, please provide the crash log or stack trace related to the crash. Include it inside a code block (surround with triple backticks ```). Please use the unsigned apk (app-tempus-debug.apk), as the logs would be illegible and therefore useless for this purpose. -->
## Screenshots ## Screenshots
<!-- If applicable, add screenshots to help explain the problem. --> <!-- If applicable, add screenshots to help explain the problem. -->

View File

@@ -3,7 +3,7 @@ name: Github Release Workflow
on: on:
push: push:
tags: tags:
- 'v[0-9]+.[0-9]+.[0-9]+' - '[0-9]+.[0-9]+.[0-9]+'
jobs: jobs:
build: build:
@@ -35,15 +35,15 @@ jobs:
echo "BUILD_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV echo "BUILD_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
echo Last build tool version is: $BUILD_TOOL_VERSION echo Last build tool version is: $BUILD_TOOL_VERSION
- name: Build All APKs - name: Build All Release APKs
id: build id: build
run: | run: |
# Build release variants # Only build release variants (removed debug builds)
bash ./gradlew assembleTempusRelease bash ./gradlew assembleTempusRelease
bash ./gradlew assembleDegoogledRelease bash ./gradlew assembleDegoogledRelease
# Build debug variants
bash ./gradlew assembleTempusDebug - name: Create Artifact Staging Directory
bash ./gradlew assembleDegoogledDebug run: mkdir -p release-artifacts
- name: Sign Tempus Release APKs - name: Sign Tempus Release APKs
id: sign_tempus_release id: sign_tempus_release
@@ -59,19 +59,24 @@ jobs:
- name: Prepare Signed Tempus APKs for Release - name: Prepare Signed Tempus APKs for Release
run: | run: |
# The signing action overwrites the original, so we find the files and rename them.
TEMPUS_PATH=app/build/outputs/apk/tempus/release TEMPUS_PATH=app/build/outputs/apk/tempus/release
# Renaming 64-bit APK echo "--- Tempus Files BEFORE Move ---"
mv $TEMPUS_PATH/*arm64-v8a*.apk ./app-tempus-arm64-v8a-release.apk ls -la $TEMPUS_PATH
echo "--------------------------------"
# Renaming 32-bit APK # FIX: Use find/xargs for robust file matching and moving.
mv $TEMPUS_PATH/*armeabi-v7a*.apk ./app-tempus-armeabi-v7a-release.apk
# Renaming 64-bit APK and moving to safe staging directory
find $TEMPUS_PATH -name '*arm64-v8a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/
mv ./release-artifacts/*arm64-v8a*signed.apk ./release-artifacts/app-tempus-arm64-v8a-release.apk
# Renaming 32-bit APK and moving to safe staging directory
find $TEMPUS_PATH -name '*armeabi-v7a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/
mv ./release-artifacts/*armeabi-v7a*signed.apk ./release-artifacts/app-tempus-armeabi-v7a-release.apk
echo "Prepared Tempus APKs." echo "Prepared Tempus APKs."
ls -la *.apk
# --- DEGOOGLED SIGNING AND RENAMING ---
- name: Sign Degoogled Release APKs - name: Sign Degoogled Release APKs
id: sign_degoogled_release id: sign_degoogled_release
uses: r0adkll/sign-android-release@v1 uses: r0adkll/sign-android-release@v1
@@ -86,17 +91,24 @@ jobs:
- name: Prepare Signed Degoogled APKs for Release - name: Prepare Signed Degoogled APKs for Release
run: | run: |
# The signing action overwrites the original, so we find the files and rename them.
DEGOOGLED_PATH=app/build/outputs/apk/degoogled/release DEGOOGLED_PATH=app/build/outputs/apk/degoogled/release
# Renaming 64-bit APK echo "--- Degoogled Files BEFORE Move ---"
mv $DEGOOGLED_PATH/*arm64-v8a*.apk ./app-degoogled-arm64-v8a-release.apk ls -la $DEGOOGLED_PATH
echo "--------------------------------"
# Renaming 32-bit APK # FIX: Use find/xargs for robust file matching and moving.
mv $DEGOOGLED_PATH/*armeabi-v7a*.apk ./app-degoogled-armeabi-v7a-release.apk
# Renaming 64-bit APK and moving to safe staging directory
find $DEGOOGLED_PATH -name '*arm64-v8a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/
mv ./release-artifacts/*arm64-v8a*signed.apk ./release-artifacts/app-degoogled-arm64-v8a-release.apk
# Renaming 32-bit APK and moving to safe staging directory
find $DEGOOGLED_PATH -name '*armeabi-v7a*signed.apk' -print0 | xargs -0 mv -t ./release-artifacts/
mv ./release-artifacts/*armeabi-v7a*signed.apk ./release-artifacts/app-degoogled-armeabi-v7a-release.apk
echo "Prepared Degoogled APKs." echo "Prepared Degoogled APKs."
ls -la *.apk ls -la ./release-artifacts/
- name: Create Release - name: Create Release
id: create_release id: create_release
@@ -107,30 +119,11 @@ jobs:
body: '> Changelog coming soon' body: '> Changelog coming soon'
draft: false draft: false
prerelease: false prerelease: false
# Attach all four files in one go to the release created above files: ./release-artifacts/*.apk
files: |
./app-tempus-arm64-v8a-release.apk
./app-tempus-armeabi-v7a-release.apk
./app-degoogled-arm64-v8a-release.apk
./app-degoogled-armeabi-v7a-release.apk
- name: Upload Debug APKs as artifacts - name: Upload Release APKs as artifacts (For easy pipeline access)
uses: actions/upload-artifact@v4
with:
name: debug-apks
path: |
app/build/outputs/apk/tempus/debug/
app/build/outputs/apk/degoogled/debug/
retention-days: 30
- name: Upload Release APKs as artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: release-apks name: release-apks
path: | path: ./release-artifacts/*.apk
./app-tempus-arm64-v8a-release.apk
./app-tempus-armeabi-v7a-release.apk
./app-degoogled-arm64-v8a-release.apk
./app-degoogled-armeabi-v7a-release.apk
retention-days: 30 retention-days: 30

View File

@@ -1,6 +1,281 @@
# Changelog # Changelog
## [4.0.2](https://github.com/eddyizm/tempo/releases/tag/v4.0.2) (2025-10-26) ## What's Changed
## [4.13.0](https://github.com/eddyizm/tempo/releases/tag/v4.13.0) (2026-03-25)
* chore(i18n): Improve Russian translation by @NikkoFox in https://github.com/eddyizm/tempus/pull/503
* feat: tile size manager by @MaFo-28 in https://github.com/eddyizm/tempus/pull/440
* chore(i18n): Translated to zh_TW by @olivertzeng in https://github.com/eddyizm/tempus/pull/494
* fix: Show full album name when displaying details by @jaime-grj in https://github.com/eddyizm/tempus/pull/508
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/509
* fix: Relocate "Offline mode" text by @jaime-grj in https://github.com/eddyizm/tempus/pull/510
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/516
* refactor: navigation and bottom sheet by @tvillega in https://github.com/eddyizm/tempus/pull/491
* feat: Logo refresh by @eddyizm in https://github.com/eddyizm/tempus/pull/498
* feat: Add 'genres' page/function to Android Auto by @Jorilx in https://github.com/eddyizm/tempus/pull/505
* feat: Added all-songs feature by @unknown0816 in https://github.com/eddyizm/tempus/pull/517
## New Contributors
* @NikkoFox made their first contribution in https://github.com/eddyizm/tempus/pull/503
* @olivertzeng made their first contribution in https://github.com/eddyizm/tempus/pull/494
* @Jorilx made their first contribution in https://github.com/eddyizm/tempus/pull/505
* @unknown0816 made their first contribution in https://github.com/eddyizm/tempus/pull/517
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.12.6...v4.13.0
## What's Changed
## [4.12.6](https://github.com/eddyizm/tempo/releases/tag/v4.12.6) (2026-03-06)
* doc: update USAGE with android auto configuration by @MaFo-28 in https://github.com/eddyizm/tempus/pull/481
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/483
* fix: remove material you dynamic theming by @tvillega in https://github.com/eddyizm/tempus/pull/484
* fix: collapse sheet on navitation change by @tvillega in https://github.com/eddyizm/tempus/pull/482
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.12.4...v4.12.5
## What's Changed
## [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)
## What's Changed
* chore: Add Obtainium badge to README by @mikaeldui in https://github.com/eddyizm/tempus/pull/280
* fix: Revert "refactor MediaService" by @eddyizm in https://github.com/eddyizm/tempus/pull/282
* feat: add play functionality to library folder/index items by @antebudimir in https://github.com/eddyizm/tempus/pull/276
* fix: start queue blocking UI by @eddyizm in https://github.com/eddyizm/tempus/pull/283
## New Contributors
* @mikaeldui made their first contribution in https://github.com/eddyizm/tempus/pull/280
* @antebudimir made their first contribution in https://github.com/eddyizm/tempus/pull/276
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.2.6...v4.3.0
## [4.2.6](https://github.com/eddyizm/tempo/releases/tag/v4.2.6) (2025-11-22)
## What's Changed
* fix: Fix player queue soft-lock by @shrapnelnet in https://github.com/eddyizm/tempus/pull/266
* chore: Add Catalan i18n by @marcriera in https://github.com/eddyizm/tempus/pull/268
* chore: Refactor MediaService by @pca006132 in https://github.com/eddyizm/tempus/pull/267
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/272
* chore(i18n): Update Italian translation by @66Bunz in https://github.com/eddyizm/tempus/pull/278
## New Contributors
* @marcriera made their first contribution in https://github.com/eddyizm/tempus/pull/268
* @66Bunz made their first contribution in https://github.com/eddyizm/tempus/pull/278
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.2.4...v4.2.6
## [4.2.4](https://github.com/eddyizm/tempo/releases/tag/v4.2.4) (2025-11-15)
## What's Changed
* chore: Update russian strings.xml by @Sevinfolds in https://github.com/eddyizm/tempus/pull/249
* fix: disallow duplicate songs in queue by @eddyizm in https://github.com/eddyizm/tempus/pull/252
* fix:github release check by @eddyizm in https://github.com/eddyizm/tempus/pull/253
* fix: Fixed crash when viewing share by @drakeerv in https://github.com/eddyizm/tempus/pull/255
* chore: Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/257
* fix: add podcast/radio channel visible when empty podcasts/radio by @eddyizm in https://github.com/eddyizm/tempus/pull/260
## New Contributors
* @Sevinfolds made their first contribution in https://github.com/eddyizm/tempus/pull/249
* @drakeerv made their first contribution in https://github.com/eddyizm/tempus/pull/255
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.2.0...v4.2.4
## [4.2.0](https://github.com/eddyizm/tempo/releases/tag/v4.2.0) (2025-11-09)
## What's Changed
* fix: Equalizer fix in main build variant by @jaime-grj in https://github.com/eddyizm/tempus/pull/239
* fix: Images not filling holder by @eddyizm in https://github.com/eddyizm/tempus/pull/244
* feat: Make artist and album clickable by @eddyizm in https://github.com/eddyizm/tempus/pull/243
* feat: implement scroll to currently playing feature by @shrapnelnet in https://github.com/eddyizm/tempus/pull/247
* fix: shuffling genres only queuing 25 songs by @shrapnelnet in https://github.com/eddyizm/tempus/pull/246
## New Contributors
* @shrapnelnet made their first contribution in https://github.com/eddyizm/tempus/pull/247
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.1.3...v4.2.0
## [4.1.3](https://github.com/eddyizm/tempo/releases/tag/v4.1.3) (2025-11-06)
## What's Changed
* [fix: equalizer missing referenced value](https://github.com/eddyizm/tempus/commit/923cfd5bc97ed7db28c90348e3619d0a784fc434)
* Fix: Album track list bug by @eddyizm in https://github.com/eddyizm/tempus/pull/237
* fix: Add listener to enable equalizer when audioSessionId changes by @jaime-grj in https://github.com/eddyizm/tempus/pull/235
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.1.0...v4.1.3
## [4.1.0](https://github.com/eddyizm/tempo/releases/tag/v4.1.0) (2025-11-05)
## What's Changed
* chore(i18n): Update Spanish (es-ES) translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/205
* shuffle for artists without using `getTopSongs` by @pca006132 in https://github.com/eddyizm/tempus/pull/207
* Update USAGE.md with instant mix details by @zc-devs in https://github.com/eddyizm/tempus/pull/220
* feat: sort artists by album count by @pca006132 in https://github.com/eddyizm/tempus/pull/206
* Fix downloaded tab performance by @pca006132 in https://github.com/eddyizm/tempus/pull/210
* fix: remove NestedScrollViews for fragment_album_page by @pca006132 in https://github.com/eddyizm/tempus/pull/216
* fix: playlist page should not snap by @pca006132 in https://github.com/eddyizm/tempus/pull/218
* fix: do not override getItemViewType and getItemId by @pca006132 in https://github.com/eddyizm/tempus/pull/221
* chore: update media3 dependencies by @pca006132 in https://github.com/eddyizm/tempus/pull/217
* fix: update MediaItems after network change by @pca006132 in https://github.com/eddyizm/tempus/pull/222
* fix: skip mapping downloaded item by @pca006132 in https://github.com/eddyizm/tempus/pull/228
## New Contributors
* @pca006132 made their first contribution in https://github.com/eddyizm/tempus/pull/207
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.0.7...v4.1.0
## [4.0.7](https://github.com/eddyizm/tempo/releases/tag/v4.0.7) (2025-10-28)
## What's Changed
* chore: updated tempo references to tempus including github check by @eddyizm in https://github.com/eddyizm/tempus/pull/197
* fix: Crash on share no expiration date or field returned from api by @eddyizm in https://github.com/eddyizm/tempus/pull/199
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.0.6...v4.0.7
## [4.0.6](https://github.com/eddyizm/tempo/releases/tag/v4.0.6) (2025-10-26)
## Attention ## Attention
This release will not update previous installs as it is considered a new app, no longer `Tempo`, new icon, new app id, and new app name. Hoping it will not be a huge inconvenience but was necessary in order to publish to app stores like IzzyDroid and FDroid. This release will not update previous installs as it is considered a new app, no longer `Tempo`, new icon, new app id, and new app name. Hoping it will not be a huge inconvenience but was necessary in order to publish to app stores like IzzyDroid and FDroid.

View File

@@ -1,25 +1,46 @@
<p align="center"> <p align="center">
<img alt="Tempus" title="Tempus" src="mockup/svg/tempus_horizontal_logo.png" width="250"> <img alt="Tempus" title="Tempus" src="mockup/svg/tempus-horizontal-banner.png" width="250">
</p> </p>
---
<p align="center"> <p align="center">
<b>Access your music library on all your android devices</b> <b>Access your music library on all your android devices</b>
</p> </p>
<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 -->
<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>
<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>
<p align="center"> <p align="center">
<a href="https://github.com/eddyizm/tempo/releases"><img src="https://i.ibb.co/q0mdc4Z/get-it-on-github.png" width="200"></a> <a href="https://github.com/eddyizm/tempus/releases"><img src="https://i.ibb.co/q0mdc4Z/get-it-on-github.png" width="200"></a>
<a href="https://apt.izzysoft.de/fdroid/index/apk/com.eddyizm.degoogled.tempus"><img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" width="200"></a>
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.eddyizm.tempus%22%2C%22url%22%3A%22https%3A%2F%2Fgithub.com%2Feddyizm%2Ftempus%22%2C%22author%22%3A%22eddyizm%22%2C%22name%22%3A%22Tempus%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22includePrereleases%5C%22%3Afalse%2C%5C%22fallbackToOlderReleases%5C%22%3Atrue%2C%5C%22filterReleaseTitlesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22filterReleaseNotesByRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22verifyLatestTag%5C%22%3Afalse%2C%5C%22sortMethodChoice%5C%22%3A%5C%22date%5C%22%2C%5C%22useLatestAssetDateAsReleaseDate%5C%22%3Afalse%2C%5C%22releaseTitleAsVersion%5C%22%3Afalse%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22releaseDateAsVersion%5C%22%3Afalse%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22tempus%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%2C%5C%22includeZips%5C%22%3Afalse%2C%5C%22zippedApkFilterRegEx%5C%22%3A%5C%22%5C%22%7D%22%2C%22overrideSource%22%3A%22GitHub%22%7D"><img width="200" src="https://github.com/user-attachments/assets/119e7ff4-2636-43cb-ab7f-1b6a58ac3570" /></a>
<a href="https://www.openapk.net/tempus/com.eddyizm.degoogled.tempus/"><img src="https://camo.githubusercontent.com/cd56895b28a73ebd781a65b4f567add5419e45797a5cf1485ce408e851c2318e/68747470733a2f2f7777772e6f70656e61706b2e6e65742f696d616765732f6f70656e61706b2d62616467652e706e67" width="200"></a>
</p> </p>
<!-- <p align="center"> <!--
<a href="https://f-droid.org/packages/com.cappielloantonio.notquitemy.tempo"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="200"></a> <a href="https://f-droid.org/packages/com.cappielloantonio.notquitemy.tempo"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="200"></a>
<a href="https://apt.izzysoft.de/fdroid/index/apk/com.cappielloantonio.tempo"><img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" width="200"></a> -->
</p> -->
**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 Last.fm to personalize your music experience. Tempus does not rely on magic algorithms to decide what you should listen to. Instead, the interface is built around your listening history, randomness, and optionally integrates with services like Listenbrainz.org and Last.fm to personalize your music experience (These must be supported by your backend).
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.**
@@ -33,16 +54,8 @@ Please note the two variants in the release assets include release/debug and 32/
`app-tempus` <- The github release with all the android auto/chromecast features `app-tempus` <- The github release with all the android auto/chromecast features
`app-degoogled*` <- The f-droid release that goes without any of the google stuff. It was last released at 3.8.1 from the original repo. Since I don't have access to that original repo, I am releasing the apk's here on github. `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)
[**Buy me a coffee**](https://ko-fi.com/eddyizm)
## 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.
@@ -51,16 +64,19 @@ 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">
@@ -68,13 +84,13 @@ Please note the two variants in the release assets include release/debug and 32/
</p> </p>
<p align="center"> <p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1_light.png" width=200> <img src="mockup/1_light_tempus.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2_light.png" width=200> <img src="mockup/2_light_tempus.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3_light.png" width=200> <img src="mockup/3_light_tempus.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4_light.png" width=200> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4_light.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5_light.png" width=200> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5_light.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6_light.png" width=200> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6_light.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/8_light.png" width=200> <!-- <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/8_light.png" width=200> -->
</p> </p>
<br> <br>
@@ -84,13 +100,13 @@ Please note the two variants in the release assets include release/debug and 32/
</p> </p>
<p align="center"> <p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1_dark.png" width=200> <img src="mockup/1_dark_tempus.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2_dark.png" width=200> <img src="mockup/2_dark_tempus.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3_dark.png" width=200> <img src="mockup/3_dark_tempus.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4_dark.png" width=200> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4_dark.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5_dark.png" width=200> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5_dark.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6_dark.png" width=200> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6_dark.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/8_dark.png" width=200> <!-- <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/8_dark.png" width=200> -->
</p> </p>
@@ -104,11 +120,20 @@ 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*
All the amazing [contributors](https://github.com/eddyizm/tempus/graphs/contributors)❤️
## Donate
[**Buy me a coffee**](https://ko-fi.com/eddyizm)
bitcoin: `3QVHSSCJvn6yXEcJ3A3cxYLMmbvFsrnUs5`
## License ## License
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)
[Opensvg.org](https://opensvg.org) for the new turntable logo. [SeattleGuy](https://github.com/SeattleGuy) for the new logo design.

124
USAGE.md
View File

@@ -12,7 +12,7 @@
- [Playlist Management](#playlist-management) - [Playlist Management](#playlist-management)
- [Android Auto](#android-auto) - [Android Auto](#android-auto)
- [Settings](#settings) - [Settings](#settings)
- [Troubleshooting](#troubleshooting) - [Known Issues](#known-issues)
## Prerequisites ## Prerequisites
@@ -27,7 +27,9 @@ This app works with any service that implements the Subsonic API, including:
- [LMS - Lightweight Music Server](https://github.com/epoupon/lms) - *personal fave and my backend* - [LMS - Lightweight Music Server](https://github.com/epoupon/lms) - *personal fave and my backend*
- [Navidrome](https://www.navidrome.org/) - [Navidrome](https://www.navidrome.org/)
- [Gonic](https://github.com/sentriz/gonic) - [Gonic](https://github.com/sentriz/gonic)
- [Ampache](https://github.com/ampache/ampache)
- [NextCloud Music](https://apps.nextcloud.com/apps/music)
- [Airsonic Advanced](https://github.com/kagemomiji/airsonic-advanced)
@@ -66,6 +68,21 @@ However, if you want to limit or change libraries you could use a workaround, if
You can create multiple users , one for each library, and save each of them in Tempus app. You can create multiple users , one for each library, and save each of them in Tempus app.
### Folder or index playback
If your Subsonic-compatible server exposes the folder tree **or** provides an artist index (for example Gonic, Navidrome, or any backend with folder browsing enabled), Tempus lets you play an entire folder from anywhere in the library hierarchy:
<p align="left">
<img src="mockup/usage/music_folders_root.png" width=317 style="margin-right:16px;">
<img src="mockup/usage/music_folders_playback.png" width=317>
</p>
- The **Library ▸ Music folders** screen shows each top-level folder with a play icon only after you drill into it. The root entry remains a simple navigator.
- When viewing **inner folders** **or artist index entries**, tap the new play button to immediately enqueue every audio track inside that folder/index and all nested subfolders.
- Video files are excluded automatically, so only playable audio ends up in the queue.
No extra config is needed—Tempus adjusts based on the connected backend.
### Now Playing Screen ### Now Playing Screen
On the main player control screen, tapping on the artwork will reveal a small collection of 4 buttons/icons. On the main player control screen, tapping on the artwork will reveal a small collection of 4 buttons/icons.
@@ -78,9 +95,23 @@ On the main player control screen, tapping on the artwork will reveal a small co
1. Downloads the track (there is a notification if the android screen but not a pop toast currently ) 1. Downloads the track (there is a notification if the android screen but not a pop toast currently )
2. Adds track to playlist - pops up playlist dialog. 2. Adds track to playlist - pops up playlist dialog.
3. Adds tracks to the queue via instant mix function 3. Adds tracks to the queue via instant mix function
* TBD: what is the _instant mix function_?
* Uses [getSimilarSongs](https://opensubsonic.netlify.app/docs/endpoints/getsimilarsongs/) of OpenSubsonic API.
Which tracks to be mixed depends on the server implementation. For example, Navidrome gets 15 similar artists from LastFM, then 20 top songs from each.
4. Saves play queue (if the feature is enabled in the settings) 4. Saves play queue (if the feature is enabled in the settings)
* if the setting is not enabled, it toggles a view of the lyrics if available (slides to the right) * if the setting is not enabled, it toggles a view of the lyrics if available (slides to the right)
### Podcasts
If your server supports it - add a podcast rss feed
<p align="left">
<img src="mockup/usage/add_podcast_feed.png" width=317>
</p>
### Radio Stations
If your server supports it - add a internet radio station feed
<p align="left">
<img src="mockup/usage/add_radio_station.png" width=326>
</p>
## Navigation ## Navigation
@@ -127,9 +158,81 @@ On the main player control screen, tapping on the artwork will reveal a small co
## Android Auto ## Android Auto
### Enabling on your head unit **Enabling on your head unit**
- 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)
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".
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>
**Interface Configuration**
The Android Auto interface can be configured by user to best suit their preferences.
<p align="left">
<img src="mockup/usage/aa_preferences.png" width=317 style="margin-right:16px;">
<img src="mockup/usage/aa_functions.png" width=317>
</p>
4 tabs can be configured with the following functions:
- Do not display : This tab is not used
- Home : Displays all functions not used in other tabs
- Recent : The 15 most recently listened-to albums
- Albums : Albums sorted by name
- Artists : Albums sorted by artist
- Playlists
- Podcast : The 100 podcasts recently added
- Radio
- Folder : Navigation through music directories
- Albums most played : The 15 most played albums
- Albums added : The 15 recently added albums
- Star tracks
- Star albums
- Star artists
- Random : 100 random songs
- Genres : 500 songs of the chosen genre OR 100 random songs if "shuffle genre songs" is selected
If all tabs are set to "Do not display", then "Home" tab will be created with all functions inside.
If "Home" is selected after another tab, it becomes "More"
In addition, you can choose to display the following functions as thumbnails or lists:
- Home
- Albums (Last played, Most played, Recently added, Artists, Star tracks, Star albums, Star artists, Random)
- Playlists
- Radio
- Podcast
<p align="left">
<img src="mockup/usage/aa_thumbnails.jpg" width=317 style="margin-right:16px;">
<img src="mockup/usage/aa_list.jpg" width=317>
</p>
The A-Z button allows you to jump to items starting with the chosen letter.
Search button returns albums or artists, even if they are not displayed by the selected function.
Results of the A-Z jump or search will always be displayed as a list.
<p align="left">
<img src="mockup/usage/aa_AZ.jpg" width=317 style="margin-right:16px;">
<img src="mockup/usage/aa_search.jpg" width=317>
</p>
Display of albums and artists is limited to 500. For large libraries, it's preferable to use star albums or star artists.
### Server Settings ### Server Settings
**IN PROGRESS** **IN PROGRESS**
@@ -145,15 +248,12 @@ On the main player control screen, tapping on the artwork will reveal a small co
### Appearance ### Appearance
**TODO** **TODO**
## Troubleshooting ## Known Issues
### Connection Issues ### Airsonic Distorted Playback
**TODO** First reported in issue [#226](https://github.com/eddyizm/tempus/issues/226)
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:
@@ -163,4 +263,4 @@ For additional help:
--- ---
*Note: This app requires a pre-existing Subsonic-compatible server with music content.* *Note: This app requires a pre-existing Subsonic-compatible server with music content.*

1
_config.yml Normal file
View File

@@ -0,0 +1 @@
markdown: GFM

View File

@@ -10,8 +10,8 @@ android {
minSdkVersion 24 minSdkVersion 24
targetSdk 35 targetSdk 35
versionCode 1 versionCode 24
versionName '4.0.2' versionName '4.13.0'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
javaCompileOptions { javaCompileOptions {
@@ -35,7 +35,12 @@ android {
} }
} }
dependenciesInfo {
// Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid)
includeInApk = false
// Disables dependency metadata when building Android App Bundles (for Google Play)
includeInBundle = false
}
flavorDimensions += "default" flavorDimensions += "default"
@@ -96,6 +101,7 @@ 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'
@@ -105,12 +111,12 @@ dependencies {
implementation 'com.github.bumptech.glide:annotations:4.16.0' implementation 'com.github.bumptech.glide:annotations:4.16.0'
// Media3 // Media3
implementation 'androidx.media3:media3-session:1.5.1' implementation 'androidx.media3:media3-session:1.8.0'
implementation 'androidx.media3:media3-common:1.5.1' implementation 'androidx.media3:media3-common:1.8.0'
implementation 'androidx.media3:media3-exoplayer:1.5.1' implementation 'androidx.media3:media3-exoplayer:1.8.0'
implementation 'androidx.media3:media3-ui:1.5.1' implementation 'androidx.media3:media3-ui:1.8.0'
implementation 'androidx.media3:media3-exoplayer-hls:1.5.1' implementation 'androidx.media3:media3-exoplayer-hls:1.8.0'
tempusImplementation 'androidx.media3:media3-cast:1.5.1' tempusImplementation 'androidx.media3:media3-cast:1.8.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,503 +1,6 @@
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.os.Binder
import android.os.Bundle
import android.os.IBinder
import android.os.Handler
import android.os.Looper
import androidx.media3.common.*
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.TrackGroupArray
import androidx.media3.exoplayer.trackselection.TrackSelectionArray
import androidx.media3.session.*
import androidx.media3.session.MediaSession.ControllerInfo
import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.repository.QueueRepository
import com.cappielloantonio.tempo.ui.activity.MainActivity
import com.cappielloantonio.tempo.util.AssetLinkUtil
import com.cappielloantonio.tempo.util.Constants
import com.cappielloantonio.tempo.util.DownloadUtil
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
import com.cappielloantonio.tempo.util.MappingUtil
import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil
import com.cappielloantonio.tempo.widget.WidgetUpdateManager
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
@UnstableApi @UnstableApi
class MediaService : MediaLibraryService() { class MediaService : BaseMediaService()
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>
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"
}
override fun onCreate() {
super.onCreate()
initializeCustomCommands()
initializePlayer()
initializeMediaLibrarySession()
restorePlayerFromQueue()
initializePlayerListener()
initializeEqualizerManager()
setPlayer(player)
}
override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession {
return mediaLibrarySession
}
override fun onDestroy() {
equalizerManager.release()
stopWidgetUpdates()
releasePlayer()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? {
// Check if the intent is for our custom equalizer binder
if (intent?.action == ACTION_BIND_EQUALIZER) {
return binder
}
// Otherwise, handle it as a normal MediaLibraryService connection
return super.onBind(intent)
}
private inner class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback {
override fun onConnect(
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
if (equalizerManager.attachToSession(audioSessionId)) {
val enabled = Preferences.isEqualizerEnabled()
equalizerManager.setEnabled(enabled)
val bands = equalizerManager.getNumberOfBands()
val savedLevels = Preferences.getEqualizerBandLevels(bands)
for (i in 0 until bands) {
equalizerManager.setBandLevel(i.toShort(), savedLevels[i])
}
}
}
private fun initializeMediaLibrarySession() {
val sessionActivityPendingIntent =
TaskStackBuilder.create(this).run {
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 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) {
ReplayGainUtil.setReplayGain(player, tracks)
val currentMediaItem = player.currentMediaItem
if (currentMediaItem != null && currentMediaItem.mediaMetadata.extras != null) {
MediaManager.scrobble(currentMediaItem, false)
}
if (player.currentMediaItemIndex + 1 == player.mediaItemCount)
MediaManager.continuousPlay(player.currentMediaItem)
}
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)
}
})
if (player.isPlaying) {
scheduleWidgetUpdates()
}
}
private fun setPlayer(player: Player) {
mediaLibrarySession.player = player
}
private fun releasePlayer() {
player.release()
mediaLibrarySession.release()
}
@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 getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
private fun getMediaSourceFactory(): MediaSource.Factory = DynamicMediaSourceFactory(this)
}
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FF36C12C"
android:pathData="M0,0h108v108h-108z" />
</vector>

View File

@@ -1,54 +1,78 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="512" android:viewportWidth="108"
android:viewportHeight="512"> android:viewportHeight="108">
<group android:scaleX="0.49"
android:scaleY="0.49"
android:translateX="130.56"
android:translateY="130.56">
<path <group
android:pathData="M512,437.33c0,11.78 -9.56,21.34 -21.34,21.34H21.33C9.55,458.67 0,449.11 0,437.33V96c0,-11.78 9.55,-21.33 21.33,-21.33h469.33c11.78,0 21.34,9.55 21.34,21.33L512,437.33L512,437.33z" android:scaleX="0.13"
android:fillColor="#8CC152"/> <path android:scaleY="0.13"
android:pathData="M512,416.01c0,11.78 -9.56,21.31 -21.34,21.31H21.33C9.55,437.33 0,427.8 0,416.01V74.67c0,-11.78 9.55,-21.34 21.33,-21.34h469.33c11.78,0 21.34,9.56 21.34,21.34L512,416.01L512,416.01z" android:translateX="21.5"
android:fillColor="#62A43B"/> <path android:translateY="21.5">
android:pathData="M63.99,160c-5.89,0 -10.66,4.78 -10.66,10.67v149.34c0,5.88 4.77,10.66 10.66,10.66c5.89,0 10.67,-4.78 10.67,-10.66V170.67C74.66,164.78 69.88,160 63.99,160z"
android:fillColor="#8CC152"/> <path <path
android:pathData="M74.66,106.67c0,5.89 -4.78,10.66 -10.67,10.66c-5.89,0 -10.66,-4.77 -10.66,-10.66S58.1,96 63.99,96C69.88,96 74.66,100.78 74.66,106.67z" android:pathData="M250,0c138.07,0 250,111.93 250,250S388.07,500 250,500 0,388.07 0,250 111.93,0 250,0ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z">
android:fillColor="#E6E9ED"/> <aapt:attr name="android:fillColor">
<path <gradient
android:pathData="M74.66,384.01c0,5.88 -4.78,10.66 -10.67,10.66c-5.89,0 -10.66,-4.78 -10.66,-10.66c0,-5.91 4.77,-10.69 10.66,-10.69C69.88,373.33 74.66,378.11 74.66,384.01z" android:startX="122.34"
android:fillColor="#E6E9ED"/> android:startY="23.55"
<path android:endX="377.69"
android:pathData="M448,123.73h-21.34v203.19l-40.31,50.41v0.02c-1.47,1.83 -2.34,4.14 -2.34,6.67c0,5.88 4.78,10.66 10.66,10.66c3.38,0 6.38,-1.56 8.33,-4h0.02l42.66,-53.34l0,0c1.47,-1.81 2.34,-4.13 2.34,-6.66V123.73z" android:endY="465.83"
android:fillColor="#E6E9ED"/> android:type="linear">
<path <item android:offset="0.0" android:color="#FF36C12C" />
android:pathData="M437.33,149.33c-11.77,0 -21.33,-9.56 -21.33,-21.33s9.56,-21.33 21.33,-21.33s21.33,9.56 21.33,21.33S449.09,149.33 437.33,149.33z" <item android:offset="1.0" android:color="#FF36C12C" />
android:fillColor="#E6E9ED"/> </gradient>
<path </aapt:attr>
android:pathData="M437.33,96c-17.67,0 -32,14.33 -32,32s14.33,32 32,32s32,-14.33 32,-32S455,96 437.33,96zM437.33,138.67c-5.89,0 -10.67,-4.8 -10.67,-10.67c0,-5.88 4.78,-10.67 10.67,-10.67s10.67,4.8 10.67,10.67C448,133.88 443.22,138.67 437.33,138.67z" </path>
android:fillColor="#CCD1D9"/>
<path <path
android:pathData="M405.33,245.33c0,82.48 -66.86,149.34 -149.33,149.34c-82.47,0 -149.33,-66.86 -149.33,-149.34C106.66,162.86 173.52,96 255.99,96C338.47,96 405.33,162.86 405.33,245.33z" android:pathData="M250.41,20.5c126.89,0 229.75,102.86 229.75,229.75c0,126.89 -102.86,229.75 -229.75,229.75c-126.89,0 -229.75,-102.86 -229.75,-229.75C20.66,123.36 123.53,20.5 250.41,20.5ZM250.85,161.82c-49.09,0 -88.88,39.79 -88.88,88.88c0,49.09 39.79,88.88 88.88,88.88c49.09,0 88.88,-39.79 88.88,-88.88c0,-49.09 -39.79,-88.88 -88.88,-88.88Z">
android:fillColor="#434A54"/> <aapt:attr name="android:fillColor">
<path <gradient
android:pathData="M266.66,149.33c0,-5.89 -4.77,-10.66 -10.67,-10.66c-58.91,0 -106.66,47.75 -106.66,106.65l0,0c0,5.89 4.77,10.67 10.67,10.67s10.67,-4.78 10.67,-10.67l0,0c0,-22.78 8.88,-44.22 24.99,-60.33c16.12,-16.13 37.55,-25 60.34,-25C261.89,160 266.66,155.22 266.66,149.33z" android:startX="116.21"
android:fillColor="#656D78"/> android:startY="67.61"
<path android:endX="403.29"
android:pathData="M352,234.67c-5.9,0 -10.67,4.77 -10.67,10.66l0,0c0,22.8 -8.88,44.23 -24.98,60.34c-16.13,16.13 -37.56,25 -60.35,25c-5.89,0 -10.66,4.78 -10.66,10.66c0,5.91 4.77,10.69 10.66,10.69c58.91,0 106.66,-47.77 106.66,-106.69C362.65,239.44 357.89,234.67 352,234.67z" android:endY="429.34"
android:fillColor="#656D78"/> android:type="linear">
<path <item android:offset="0.0" android:color="#66060606" />
android:pathData="M255.99,288.01c-23.52,0 -42.66,-19.16 -42.66,-42.69c0,-23.52 19.14,-42.66 42.66,-42.66c23.54,0 42.66,19.14 42.66,42.66C298.65,268.86 279.53,288.01 255.99,288.01z" <item android:offset="1.0" android:color="#CC060606" />
android:fillColor="#FFCE54"/> </gradient>
<path </aapt:attr>
android:pathData="M255.99,192c-29.45,0 -53.33,23.88 -53.33,53.33s23.88,53.34 53.33,53.34c29.46,0 53.34,-23.89 53.34,-53.34S285.45,192 255.99,192zM255.99,277.34c-17.64,0 -32,-14.36 -32,-32.02c0,-17.64 14.36,-32 32,-32c17.65,0 32.01,14.36 32.01,32C288,262.98 273.64,277.34 255.99,277.34z" </path>
android:fillColor="#F6BB42"/>
<path <path
android:pathData="M266.66,245.33c0,5.89 -4.77,10.67 -10.67,10.67c-5.89,0 -10.66,-4.78 -10.66,-10.67s4.77,-10.66 10.66,-10.66C261.89,234.67 266.66,239.44 266.66,245.33z" android:pathData="M453.23,307.8c-18.5,72.24 -73.8,129.26 -144.2,148.92l-36.39,-138.74c21.97,-7.21 39.22,-24.84 45.88,-47.06l134.71,36.88Z">
android:fillColor="#434A54"/> <aapt:attr name="android:fillColor">
<path <gradient
android:pathData="M74.66,234.67H53.33c-5.89,0 -10.66,4.77 -10.66,10.66s4.77,10.67 10.66,10.67h21.34c5.89,0 10.66,-4.78 10.66,-10.67S80.56,234.67 74.66,234.67z" android:startX="420.63"
android:fillColor="#434A54"/> android:startY="403.74"
</group> android:endX="78.4"
android:endY="117.92"
android:type="linear">
<item android:offset="0.0" android:color="#33FFFFFF" />
<item android:offset="1.0" android:color="#4DFFFFFF" />
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M228.3,183.04c-21.73,7.15 -38.82,24.5 -45.62,46.39L47.5,192.42c18.5,-72.24 73.8,-129.26 144.2,-148.92l36.6,139.54Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="420.63"
android:startY="403.74"
android:endX="78.4"
android:endY="117.92"
android:type="linear">
<item android:offset="0.0" android:color="#33FFFFFF" />
<item android:offset="1.0" android:color="#4DFFFFFF" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#66FFFFFF"
android:pathData="M250.5,179.5c39.21,0 71,31.79 71,71s-31.79,71 -71,71s-71,-31.79 -71,-71s31.79,-71 71,-71ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z" />
</group>
</vector> </vector>

View File

@@ -1,53 +1,77 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="512" android:viewportWidth="108"
android:viewportHeight="512"> android:viewportHeight="108">
<group android:scaleX="0.55"
android:scaleY="0.55" <group
android:translateX="150.56" android:scaleX="0.13"
android:translateY="150.56"> android:scaleY="0.13"
android:translateX="21.5"
android:translateY="21.5">
<path <path
android:pathData="M512,437.33c0,11.78 -9.56,21.34 -21.34,21.34H21.33C9.55,458.67 0,449.11 0,437.33V96c0,-11.78 9.55,-21.33 21.33,-21.33h469.33c11.78,0 21.34,9.55 21.34,21.33L512,437.33L512,437.33z" android:pathData="M250,0c138.07,0 250,111.93 250,250S388.07,500 250,500 0,388.07 0,250 111.93,0 250,0ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z">
android:fillColor="#8CC152"/> <path <aapt:attr name="android:fillColor">
android:pathData="M512,416.01c0,11.78 -9.56,21.31 -21.34,21.31H21.33C9.55,437.33 0,427.8 0,416.01V74.67c0,-11.78 9.55,-21.34 21.33,-21.34h469.33c11.78,0 21.34,9.56 21.34,21.34L512,416.01L512,416.01z" <gradient
android:fillColor="#62A43B"/> <path android:startX="122.34"
android:pathData="M63.99,160c-5.89,0 -10.66,4.78 -10.66,10.67v149.34c0,5.88 4.77,10.66 10.66,10.66c5.89,0 10.67,-4.78 10.67,-10.66V170.67C74.66,164.78 69.88,160 63.99,160z" android:startY="23.55"
android:fillColor="#8CC152"/> <path android:endX="377.69"
android:pathData="M74.66,106.67c0,5.89 -4.78,10.66 -10.67,10.66c-5.89,0 -10.66,-4.77 -10.66,-10.66S58.1,96 63.99,96C69.88,96 74.66,100.78 74.66,106.67z" android:endY="465.83"
android:fillColor="#E6E9ED"/> android:type="linear">
<item android:offset="0.0" android:color="#FF36C12C" />
<item android:offset="1.0" android:color="#FF36C12C" />
</gradient>
</aapt:attr>
</path>
<path <path
android:pathData="M74.66,384.01c0,5.88 -4.78,10.66 -10.67,10.66c-5.89,0 -10.66,-4.78 -10.66,-10.66c0,-5.91 4.77,-10.69 10.66,-10.69C69.88,373.33 74.66,378.11 74.66,384.01z" android:pathData="M250.41,20.5c126.89,0 229.75,102.86 229.75,229.75c0,126.89 -102.86,229.75 -229.75,229.75c-126.89,0 -229.75,-102.86 -229.75,-229.75C20.66,123.36 123.53,20.5 250.41,20.5ZM250.85,161.82c-49.09,0 -88.88,39.79 -88.88,88.88c0,49.09 39.79,88.88 88.88,88.88c49.09,0 88.88,-39.79 88.88,-88.88c0,-49.09 -39.79,-88.88 -88.88,-88.88Z">
android:fillColor="#E6E9ED"/> <aapt:attr name="android:fillColor">
<gradient
android:startX="116.21"
android:startY="67.61"
android:endX="403.29"
android:endY="429.34"
android:type="linear">
<item android:offset="0.0" android:color="#66060606" />
<item android:offset="1.0" android:color="#CC060606" />
</gradient>
</aapt:attr>
</path>
<path <path
android:pathData="M448,123.73h-21.34v203.19l-40.31,50.41v0.02c-1.47,1.83 -2.34,4.14 -2.34,6.67c0,5.88 4.78,10.66 10.66,10.66c3.38,0 6.38,-1.56 8.33,-4h0.02l42.66,-53.34l0,0c1.47,-1.81 2.34,-4.13 2.34,-6.66V123.73z" android:pathData="M453.23,307.8c-18.5,72.24 -73.8,129.26 -144.2,148.92l-36.39,-138.74c21.97,-7.21 39.22,-24.84 45.88,-47.06l134.71,36.88Z">
android:fillColor="#E6E9ED"/> <aapt:attr name="android:fillColor">
<gradient
android:startX="420.63"
android:startY="403.74"
android:endX="78.4"
android:endY="117.92"
android:type="linear">
<item android:offset="0.0" android:color="#33FFFFFF" />
<item android:offset="1.0" android:color="#4DFFFFFF" />
</gradient>
</aapt:attr>
</path>
<path <path
android:pathData="M437.33,149.33c-11.77,0 -21.33,-9.56 -21.33,-21.33s9.56,-21.33 21.33,-21.33s21.33,9.56 21.33,21.33S449.09,149.33 437.33,149.33z" android:pathData="M228.3,183.04c-21.73,7.15 -38.82,24.5 -45.62,46.39L47.5,192.42c18.5,-72.24 73.8,-129.26 144.2,-148.92l36.6,139.54Z">
android:fillColor="#E6E9ED"/> <aapt:attr name="android:fillColor">
<gradient
android:startX="420.63"
android:startY="403.74"
android:endX="78.4"
android:endY="117.92"
android:type="linear">
<item android:offset="0.0" android:color="#33FFFFFF" />
<item android:offset="1.0" android:color="#4DFFFFFF" />
</gradient>
</aapt:attr>
</path>
<path <path
android:pathData="M437.33,96c-17.67,0 -32,14.33 -32,32s14.33,32 32,32s32,-14.33 32,-32S455,96 437.33,96zM437.33,138.67c-5.89,0 -10.67,-4.8 -10.67,-10.67c0,-5.88 4.78,-10.67 10.67,-10.67s10.67,4.8 10.67,10.67C448,133.88 443.22,138.67 437.33,138.67z" android:fillColor="#66FFFFFF"
android:fillColor="#CCD1D9"/> android:pathData="M250.5,179.5c39.21,0 71,31.79 71,71s-31.79,71 -71,71s-71,-31.79 -71,-71s31.79,-71 71,-71ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z" />
<path </group>
android:pathData="M405.33,245.33c0,82.48 -66.86,149.34 -149.33,149.34c-82.47,0 -149.33,-66.86 -149.33,-149.34C106.66,162.86 173.52,96 255.99,96C338.47,96 405.33,162.86 405.33,245.33z"
android:fillColor="#434A54"/>
<path
android:pathData="M266.66,149.33c0,-5.89 -4.77,-10.66 -10.67,-10.66c-58.91,0 -106.66,47.75 -106.66,106.65l0,0c0,5.89 4.77,10.67 10.67,10.67s10.67,-4.78 10.67,-10.67l0,0c0,-22.78 8.88,-44.22 24.99,-60.33c16.12,-16.13 37.55,-25 60.34,-25C261.89,160 266.66,155.22 266.66,149.33z"
android:fillColor="#656D78"/>
<path
android:pathData="M352,234.67c-5.9,0 -10.67,4.77 -10.67,10.66l0,0c0,22.8 -8.88,44.23 -24.98,60.34c-16.13,16.13 -37.56,25 -60.35,25c-5.89,0 -10.66,4.78 -10.66,10.66c0,5.91 4.77,10.69 10.66,10.69c58.91,0 106.66,-47.77 106.66,-106.69C362.65,239.44 357.89,234.67 352,234.67z"
android:fillColor="#656D78"/>
<path
android:pathData="M255.99,288.01c-23.52,0 -42.66,-19.16 -42.66,-42.69c0,-23.52 19.14,-42.66 42.66,-42.66c23.54,0 42.66,19.14 42.66,42.66C298.65,268.86 279.53,288.01 255.99,288.01z"
android:fillColor="#FFCE54"/>
<path
android:pathData="M255.99,192c-29.45,0 -53.33,23.88 -53.33,53.33s23.88,53.34 53.33,53.34c29.46,0 53.34,-23.89 53.34,-53.34S285.45,192 255.99,192zM255.99,277.34c-17.64,0 -32,-14.36 -32,-32.02c0,-17.64 14.36,-32 32,-32c17.65,0 32.01,14.36 32.01,32C288,262.98 273.64,277.34 255.99,277.34z"
android:fillColor="#F6BB42"/>
<path
android:pathData="M266.66,245.33c0,5.89 -4.77,10.67 -10.67,10.67c-5.89,0 -10.66,-4.78 -10.66,-10.67s4.77,-10.66 10.66,-10.66C261.89,234.67 266.66,239.44 266.66,245.33z"
android:fillColor="#434A54"/>
<path
android:pathData="M74.66,234.67H53.33c-5.89,0 -10.66,4.77 -10.66,10.66s4.77,10.67 10.66,10.67h21.34c5.89,0 10.66,-4.78 10.66,-10.67S80.56,234.67 74.66,234.67z"
android:fillColor="#434A54"/>
</group>
</vector> </vector>

View File

@@ -0,0 +1,77 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="512"
android:viewportHeight="522">
<group
android:scaleX="1.0"
android:scaleY="1.0"
android:translateX="14.0"
android:translateY="14.0">
<path
android:pathData="M250,0c138.07,0 250,111.93 250,250S388.07,500 250,500 0,388.07 0,250 111.93,0 250,0ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="122.34"
android:startY="23.55"
android:endX="377.69"
android:endY="465.83"
android:type="linear">
<item android:offset="0.0" android:color="#FF36C12C" />
<item android:offset="1.0" android:color="#FF36C12C" />
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M250.41,20.5c126.89,0 229.75,102.86 229.75,229.75c0,126.89 -102.86,229.75 -229.75,229.75c-126.89,0 -229.75,-102.86 -229.75,-229.75C20.66,123.36 123.53,20.5 250.41,20.5ZM250.85,161.82c-49.09,0 -88.88,39.79 -88.88,88.88c0,49.09 39.79,88.88 88.88,88.88c49.09,0 88.88,-39.79 88.88,-88.88c0,-49.09 -39.79,-88.88 -88.88,-88.88Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="116.21"
android:startY="67.61"
android:endX="403.29"
android:endY="429.34"
android:type="linear">
<item android:offset="0.0" android:color="#66060606" />
<item android:offset="1.0" android:color="#CC060606" />
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M453.23,307.8c-18.5,72.24 -73.8,129.26 -144.2,148.92l-36.39,-138.74c21.97,-7.21 39.22,-24.84 45.88,-47.06l134.71,36.88Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="420.63"
android:startY="403.74"
android:endX="78.4"
android:endY="117.92"
android:type="linear">
<item android:offset="0.0" android:color="#33FFFFFF" />
<item android:offset="1.0" android:color="#4DFFFFFF" />
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M228.3,183.04c-21.73,7.15 -38.82,24.5 -45.62,46.39L47.5,192.42c18.5,-72.24 73.8,-129.26 144.2,-148.92l36.6,139.54Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="420.63"
android:startY="403.74"
android:endX="78.4"
android:endY="117.92"
android:type="linear">
<item android:offset="0.0" android:color="#33FFFFFF" />
<item android:offset="1.0" android:color="#4DFFFFFF" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#66FFFFFF"
android:pathData="M250.5,179.5c39.21,0 71,31.79 71,71s-31.79,71 -71,71s-71,-31.79 -71,-71s31.79,-71 71,-71ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z" />
</group>
</vector>

View File

@@ -0,0 +1,78 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="0.16"
android:scaleY="0.16"
android:translateX="14.0"
android:translateY="14.0">
<path
android:pathData="M250,0c138.07,0 250,111.93 250,250S388.07,500 250,500 0,388.07 0,250 111.93,0 250,0ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="122.34"
android:startY="23.55"
android:endX="377.69"
android:endY="465.83"
android:type="linear">
<item android:offset="0.0" android:color="#FF36C12C" />
<item android:offset="1.0" android:color="#FF36C12C" />
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M250.41,20.5c126.89,0 229.75,102.86 229.75,229.75c0,126.89 -102.86,229.75 -229.75,229.75c-126.89,0 -229.75,-102.86 -229.75,-229.75C20.66,123.36 123.53,20.5 250.41,20.5ZM250.85,161.82c-49.09,0 -88.88,39.79 -88.88,88.88c0,49.09 39.79,88.88 88.88,88.88c49.09,0 88.88,-39.79 88.88,-88.88c0,-49.09 -39.79,-88.88 -88.88,-88.88Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="116.21"
android:startY="67.61"
android:endX="403.29"
android:endY="429.34"
android:type="linear">
<item android:offset="0.0" android:color="#66060606" />
<item android:offset="1.0" android:color="#CC060606" />
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M453.23,307.8c-18.5,72.24 -73.8,129.26 -144.2,148.92l-36.39,-138.74c21.97,-7.21 39.22,-24.84 45.88,-47.06l134.71,36.88Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="420.63"
android:startY="403.74"
android:endX="78.4"
android:endY="117.92"
android:type="linear">
<item android:offset="0.0" android:color="#33FFFFFF" />
<item android:offset="1.0" android:color="#4DFFFFFF" />
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M228.3,183.04c-21.73,7.15 -38.82,24.5 -45.62,46.39L47.5,192.42c18.5,-72.24 73.8,-129.26 144.2,-148.92l36.6,139.54Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="420.63"
android:startY="403.74"
android:endX="78.4"
android:endY="117.92"
android:type="linear">
<item android:offset="0.0" android:color="#33FFFFFF" />
<item android:offset="1.0" android:color="#4DFFFFFF" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#66FFFFFF"
android:pathData="M250.5,179.5c39.21,0 71,31.79 71,71s-31.79,71 -71,71s-71,-31.79 -71,-71s31.79,-71 71,-71ZM250,235c-8.28,0 -15,6.72 -15,15c0,8.28 6.72,15 15,15c8.28,0 15,-6.72 15,-15c0,-8.28 -6.72,-15 -15,-15Z" />
</group>
</vector>

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#626A75</color>
</resources>

View File

@@ -96,7 +96,12 @@
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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -11,6 +11,7 @@ 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 {
@@ -31,6 +32,8 @@ 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() {
@@ -55,6 +58,48 @@ 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) {

View File

@@ -30,9 +30,13 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
@UnstableApi @UnstableApi
@Database( @Database(
version = 12, version = 14,
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 = {@AutoMigration(from = 10, to = 11), @AutoMigration(from = 11, to = 12)} autoMigrations = {
@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,6 +19,9 @@ 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,9 +12,12 @@ import java.util.List;
@Dao @Dao
public interface RecentSearchDao { public interface RecentSearchDao {
@Query("SELECT * FROM recent_search ORDER BY search DESC") @Query("SELECT search FROM recent_search ORDER BY timestamp DESC")
List<String> getRecent(); 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

@@ -3,8 +3,8 @@ package com.cappielloantonio.tempo.github;
import com.cappielloantonio.tempo.github.api.release.ReleaseClient; import com.cappielloantonio.tempo.github.api.release.ReleaseClient;
public class Github { public class Github {
private static final String OWNER = "CappielloAntonio"; private static final String OWNER = "eddyizm";
private static final String REPO = "Tempo"; private static final String REPO = "Tempus";
private ReleaseClient releaseClient; private ReleaseClient releaseClient;
public ReleaseClient getReleaseClient() { public ReleaseClient getReleaseClient() {

View File

@@ -7,10 +7,11 @@ public class UpdateUtil {
public static boolean showUpdateDialog(LatestRelease release) { public static boolean showUpdateDialog(LatestRelease release) {
if (release.getTagName() == null) return false; if (release.getTagName() == null) return false;
String remoteTag = release.getTagName().replaceAll("^\\D+", "");
try { try {
String[] local = BuildConfig.VERSION_NAME.split("\\."); String[] local = BuildConfig.VERSION_NAME.split("\\.");
String[] remote = release.getTagName().split("\\."); String[] remote = remoteTag.split("\\.");
for (int i = 0; i < local.length; i++) { for (int i = 0; i < local.length; i++) {
int localPart = Integer.parseInt(local[i]); int localPart = Integer.parseInt(local[i]);

View File

@@ -27,8 +27,11 @@ public interface ClickCallback {
default void onInternetRadioStationClick(Bundle bundle) {} default void onInternetRadioStationClick(Bundle bundle) {}
default void onInternetRadioStationLongClick(Bundle bundle) {} default void onInternetRadioStationLongClick(Bundle bundle) {}
default void onMusicFolderClick(Bundle bundle) {} default void onMusicFolderClick(Bundle bundle) {}
default void onMusicFolderPlay(Bundle bundle) {}
default void onMusicDirectoryClick(Bundle bundle) {} default void onMusicDirectoryClick(Bundle bundle) {}
default void onMusicDirectoryPlay(Bundle bundle) {}
default void onMusicIndexClick(Bundle bundle) {} default void onMusicIndexClick(Bundle bundle) {}
default void onMusicIndexPlay(Bundle bundle) {}
default void onDownloadGroupLongClick(Bundle bundle) {} default void onDownloadGroupLongClick(Bundle bundle) {}
default void onShareClick(Bundle bundle) {} default void onShareClick(Bundle bundle) {}
default void onShareLongClick(Bundle bundle) {} default void onShareLongClick(Bundle bundle) {}

View File

@@ -13,5 +13,8 @@ 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,7 +2,6 @@ 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
@@ -35,5 +34,8 @@ 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,5 +1,6 @@
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
@@ -13,6 +14,7 @@ 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
@@ -193,11 +195,20 @@ 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 = Uri.parse(CustomGlideRequest.createUrl(coverArtId, getImageSize())) val artworkUri = if (coverArtId != null) AlbumArtContentProvider.contentUri(coverArtId!!) else null
val bundle = Bundle() val bundle = Bundle()
bundle.putString("id", id) bundle.putString("id", id)
@@ -227,7 +238,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", Constants.MEDIA_TYPE_MUSIC) bundle.putString("type", type)
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

@@ -0,0 +1,44 @@
package com.cappielloantonio.tempo.navigation;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.navigation.NavController;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
public class NavigationController {
NavigationHelper helper;
public NavigationController(@NonNull NavigationHelper helper) {
this.helper = helper;
}
public void syncWithBottomSheetBehavior(BottomSheetBehavior<View> bottomSheetBehavior,
NavController navController) {
helper.syncWithBottomSheetBehavior(bottomSheetBehavior, navController);
}
public void setNavbarVisibility(boolean visibility) {
helper.setBottomNavigationBarVisibility(visibility);
}
public void setDrawerLock(boolean visibility) {
helper.setNavigationDrawerLock(visibility);
}
public boolean isNavigationDrawerLocked() {
return helper.isNavigationDrawerLocked();
}
public void toggleDrawerLockOnOrientation(AppCompatActivity activity) {
helper.toggleNavigationDrawerLockOnOrientationChange(activity);
}
public void setSystemBarsVisibility(AppCompatActivity activity, boolean visibility) {
helper.setSystemBarsVisibility(activity, visibility);
}
}

View File

@@ -0,0 +1,167 @@
package com.cappielloantonio.tempo.navigation;
import android.content.res.Configuration;
import android.view.View;
import android.view.Window;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController;
import androidx.navigation.NavDestination;
import androidx.navigation.fragment.NavHostFragment;
import androidx.navigation.ui.NavigationUI;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.util.Preferences;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import com.google.android.material.navigation.NavigationView;
import org.jetbrains.annotations.Contract;
public class NavigationHelper {
/* UI components */
private BottomNavigationView bottomNavigationView;
private FrameLayout bottomNavigationViewFrame;
private DrawerLayout drawerLayout;
/* Navigation components */
private NavigationView navigationView;
private NavHostFragment navHostFragment;
/* States that need to be remembered */
// -- //
/* Private constructor */
public NavigationHelper(@NonNull BottomNavigationView bottomNavigationView,
@NonNull FrameLayout bottomNavigationViewFrame,
@NonNull DrawerLayout drawerLayout,
@NonNull NavigationView navigationView,
@NonNull NavHostFragment navHostFragment) {
this.bottomNavigationView = bottomNavigationView;
this.bottomNavigationViewFrame = bottomNavigationViewFrame;
this.drawerLayout = drawerLayout;
this.navigationView = navigationView;
this.navHostFragment = navHostFragment;
}
public void syncWithBottomSheetBehavior(@NonNull BottomSheetBehavior<View> bottomSheetBehavior,
@NonNull NavController navController) {
navController.addOnDestinationChangedListener(
(controller, destination, arguments) -> {
// React to the user clicking one of these on bottom-navbar/drawer
boolean isTarget = isTargetDestination(destination);
int currentState = bottomSheetBehavior.getState();
if (isTarget && currentState == BottomSheetBehavior.STATE_EXPANDED) {
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
});
NavigationUI.setupWithNavController(bottomNavigationView, navController);
NavigationUI.setupWithNavController(navigationView, navController);
}
@Contract(pure = true)
private static boolean isTargetDestination(NavDestination destination) {
int destId = destination.getId();
return destId == R.id.homeFragment ||
destId == R.id.libraryFragment ||
destId == R.id.downloadFragment ||
destId == R.id.albumCatalogueFragment ||
destId == R.id.artistCatalogueFragment ||
destId == R.id.genreCatalogueFragment ||
destId == R.id.playlistCatalogueFragment;
}
/*
Clean public methods
Removes the need to invoke the activity on the fragment
*/
public void setBottomNavigationBarVisibility(boolean visible) {
int visibility = visible
? View.VISIBLE
: View.GONE;
bottomNavigationView.setVisibility(visibility);
bottomNavigationViewFrame.setVisibility(visibility);
}
public void setNavigationDrawerLock(boolean locked) {
int mode = locked
? DrawerLayout.LOCK_MODE_LOCKED_CLOSED
: DrawerLayout.LOCK_MODE_UNLOCKED;
drawerLayout.setDrawerLockMode(mode);
}
public boolean isNavigationDrawerLocked() {
return drawerLayout.getDrawerLockMode(navigationView) != DrawerLayout.LOCK_MODE_UNLOCKED;
}
@OptIn(markerClass = UnstableApi.class)
public void toggleNavigationDrawerLockOnOrientationChange(
AppCompatActivity activity) {
int orientation = activity.getResources().getConfiguration().orientation;
boolean isLandscape = orientation == Configuration.ORIENTATION_LANDSCAPE;
if (Preferences.getEnableDrawerOnPortrait()) {
setNavigationDrawerLock(false);
return;
}
setNavigationDrawerLock(!isLandscape);
}
/*
All of these are the "backward compatible" changes that don't break the assumption
that everything was defined on the activity and is gobally available
*/
@NonNull
public BottomNavigationView getBottomNavigationView() {
return bottomNavigationView;
}
@NonNull
public FrameLayout getBottomNavigationViewFrame() {
return bottomNavigationViewFrame;
}
@NonNull
public DrawerLayout getDrawerLayout() {
return drawerLayout;
}
/*
Auxiliar functions, could be moved somewhere else
*/
@OptIn(markerClass = UnstableApi.class)
public void setSystemBarsVisibility(AppCompatActivity activity, boolean visibility) {
WindowInsetsControllerCompat insetsController;
Window window = activity.getWindow();
View decorView = window.getDecorView();
insetsController = new WindowInsetsControllerCompat(window, decorView);
if (visibility) {
WindowCompat.setDecorFitsSystemWindows(window, true);
insetsController.show(WindowInsetsCompat.Type.navigationBars());
insetsController.show(WindowInsetsCompat.Type.statusBars());
insetsController.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_DEFAULT);
} else {
WindowCompat.setDecorFitsSystemWindows(window, false);
insetsController.hide(WindowInsetsCompat.Type.navigationBars());
insetsController.hide(WindowInsetsCompat.Type.statusBars());
insetsController.setSystemBarsBehavior(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
}
}

View File

@@ -0,0 +1,159 @@
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,15 +2,16 @@ 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;
@@ -204,29 +205,12 @@ public class AlbumRepository {
return albumInfo; return albumInfo;
} }
public void getInstantMix(AlbumID3 album, int count, MediaCallback callback) { public MutableLiveData<List<Child>> getInstantMix(AlbumID3 album, int count) {
App.getSubsonicClientInstance(false) // Delegate to the centralized SongRepository
.getBrowsingClient() return new SongRepository().getInstantMix(album.getId(), SeedType.ALBUM, count);
.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<>();
@@ -237,7 +221,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);
@@ -298,4 +282,4 @@ public class AlbumRepository {
} }
}); });
} }
} }

View File

@@ -5,17 +5,21 @@ 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.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.Callback; import retrofit2.Callback;
@@ -147,7 +151,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 != null && index.getArtists() != null) { if(index.getArtists() != null) {
artists.addAll(index.getArtists()); artists.addAll(index.getArtists());
} }
} }
@@ -285,26 +289,8 @@ public class ArtistRepository {
} }
public MutableLiveData<List<Child>> getInstantMix(ArtistID3 artist, int count) { public MutableLiveData<List<Child>> getInstantMix(ArtistID3 artist, int count) {
MutableLiveData<List<Child>> instantMix = new MutableLiveData<>(); // Delegate to the centralized SongRepository
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) {
@@ -312,24 +298,42 @@ public class ArtistRepository {
App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getBrowsingClient() .getBrowsingClient()
.getTopSongs(artist.getName(), count) .getArtist(artist.getId())
.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().getTopSongs() != null && response.body().getSubsonicResponse().getTopSongs().getSongs() != null) { if (response.isSuccessful() && response.body() != null &&
List<Child> songs = response.body().getSubsonicResponse().getTopSongs().getSongs(); response.body().getSubsonicResponse().getArtist() != null &&
response.body().getSubsonicResponse().getArtist().getAlbums() != null) {
if (songs != null && !songs.isEmpty()) { List<AlbumID3> albums = response.body().getSubsonicResponse().getArtist().getAlbums();
Collections.shuffle(songs); Log.d("ArtistRepository", "Got albums directly: " + albums.size());
if (albums.isEmpty()) {
Log.d("ArtistRepository", "No albums found in artist response");
return;
} }
randomSongs.setValue(songs); Collections.shuffle(albums);
int[] counts = albums.stream().mapToInt(AlbumID3::getSongCount).toArray();
Arrays.parallelPrefix(counts, Integer::sum);
int albumLimit = 0;
int multiplier = 4; // get more than the limit so we can shuffle them
while (albumLimit < albums.size() && counts[albumLimit] < count * multiplier)
albumLimit++;
Log.d("ArtistRepository", String.format("Retaining %d/%d albums", albumLimit, albums.size()));
fetchAllAlbumSongsWithCallback(albums.stream().limit(albumLimit).collect(Collectors.toList()), songs -> {
Collections.shuffle(songs);
randomSongs.setValue(songs.stream().limit(count).collect(Collectors.toList()));
});
} else {
Log.d("ArtistRepository", "Failed to get artist info");
} }
} }
@Override @Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) { public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.d("ArtistRepository", "Error getting artist info: " + t.getMessage());
} }
}); });

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,6 +22,7 @@ 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;
@@ -34,6 +35,7 @@ import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
import com.cappielloantonio.tempo.subsonic.models.MusicFolder; import com.cappielloantonio.tempo.subsonic.models.MusicFolder;
import com.cappielloantonio.tempo.subsonic.models.Playlist; import com.cappielloantonio.tempo.subsonic.models.Playlist;
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode; import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
import com.cappielloantonio.tempo.subsonic.models.Genre;
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.MusicUtil; import com.cappielloantonio.tempo.util.MusicUtil;
@@ -67,10 +69,20 @@ 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 = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize())); Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(album.getName()) .setTitle(album.getName())
@@ -217,7 +229,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>(); List<MediaItem> mediaItems = new ArrayList<>();
for (AlbumID3 album : albums) { for (AlbumID3 album : albums) {
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize())); Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(album.getName()) .setTitle(album.getName())
@@ -272,7 +284,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>(); List<MediaItem> mediaItems = new ArrayList<>();
for (ArtistID3 artist : artists) { for (ArtistID3 artist : artists) {
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(artist.getCoverArtId(), Preferences.getImageSize())); Uri artworkUri = AlbumArtContentProvider.contentUri(artist.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(artist.getName()) .setTitle(artist.getName())
@@ -397,7 +409,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 = Uri.parse(CustomGlideRequest.createUrl(song.getCoverArtId(), Preferences.getImageSize())); Uri artworkUri = AlbumArtContentProvider.contentUri(song.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(song.getTitle()) .setTitle(song.getTitle())
@@ -451,7 +463,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 = Uri.parse(CustomGlideRequest.createUrl(child.getCoverArtId(), Preferences.getImageSize())); Uri artworkUri = AlbumArtContentProvider.contentUri(child.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(child.getTitle()) .setTitle(child.getTitle())
@@ -550,7 +562,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>(); List<MediaItem> mediaItems = new ArrayList<>();
for (PodcastEpisode episode : episodes) { for (PodcastEpisode episode : episodes) {
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(episode.getCoverArtId(), Preferences.getImageSize())); Uri artworkUri = AlbumArtContentProvider.contentUri(episode.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(episode.getTitle()) .setTitle(episode.getTitle())
@@ -604,20 +616,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>(); List<MediaItem> mediaItems = new ArrayList<>();
for (InternetRadioStation radioStation : radioStations) { for (InternetRadioStation radioStation : radioStations) {
MediaMetadata mediaMetadata = new MediaMetadata.Builder() mediaItems.add(MappingUtil.mapInternetRadioStation(radioStation));
.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);
@@ -687,7 +686,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>(); List<MediaItem> mediaItems = new ArrayList<>();
for (AlbumID3 album : albums) { for (AlbumID3 album : albums) {
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize())); Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(album.getName()) .setTitle(album.getName())
@@ -791,7 +790,7 @@ public class AutomotiveRepository {
App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getSearchingClient() .getSearchingClient()
.search3(query, 20, 20, 20) .search3(query, 20, 0, 20, 0, 20, 0)
.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) {
@@ -800,7 +799,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 = Uri.parse(CustomGlideRequest.createUrl(artist.getCoverArtId(), Preferences.getImageSize())); Uri artworkUri = AlbumArtContentProvider.contentUri(artist.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(artist.getName()) .setTitle(artist.getName())
@@ -822,7 +821,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 = Uri.parse(CustomGlideRequest.createUrl(album.getCoverArtId(), Preferences.getImageSize())); Uri artworkUri = AlbumArtContentProvider.contentUri(album.getCoverArtId());
MediaMetadata mediaMetadata = new MediaMetadata.Builder() MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(album.getName()) .setTitle(album.getName())
@@ -954,6 +953,116 @@ public class AutomotiveRepository {
thread.start(); thread.start();
} }
public ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> getGenres(String prefix) {
final SettableFuture<LibraryResult<ImmutableList<MediaItem>>> listenableFuture = SettableFuture.create();
App.getSubsonicClientInstance(false)
.getBrowsingClient()
.getGenres()
.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().getGenres() != null && response.body().getSubsonicResponse().getGenres().getGenres() != null) {
List<Genre> genres = response.body().getSubsonicResponse().getGenres().getGenres();
// Sort genres alphabetically by name
genres.sort((g1, g2) -> {
String name1 = g1.getGenre() != null ? g1.getGenre() : "";
String name2 = g2.getGenre() != null ? g2.getGenre() : "";
return name1.compareToIgnoreCase(name2);
});
List<MediaItem> mediaItems = new ArrayList<>();
for (Genre genre : genres) {
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(genre.getGenre())
.setIsBrowsable(true)
.setIsPlayable(false)
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.build();
MediaItem mediaItem = new MediaItem.Builder()
.setMediaId(prefix + genre.getGenre())
.setMediaMetadata(mediaMetadata)
.setUri("")
.build();
mediaItems.add(mediaItem);
}
LibraryResult<ImmutableList<MediaItem>> libraryResult = LibraryResult.ofItemList(ImmutableList.copyOf(mediaItems), null);
listenableFuture.set(libraryResult);
} else {
listenableFuture.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE));
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
listenableFuture.setException(t);
}
});
return listenableFuture;
}
public ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> getSongsByGenre(String genre, int count, boolean shuffle) {
final SettableFuture<LibraryResult<ImmutableList<MediaItem>>> listenableFuture = SettableFuture.create();
Call<ApiResponse> call;
if (shuffle) {
call = App.getSubsonicClientInstance(false)
.getAlbumSongListClient()
.getRandomSongs(count, null, null, genre);
} else {
call = App.getSubsonicClientInstance(false)
.getAlbumSongListClient()
.getSongsByGenre(genre, count, 0);
}
call.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null) {
List<com.cappielloantonio.tempo.subsonic.models.Child> songs;
if (shuffle) {
songs = response.body().getSubsonicResponse().getRandomSongs() != null
? response.body().getSubsonicResponse().getRandomSongs().getSongs()
: null;
} else {
songs = response.body().getSubsonicResponse().getSongsByGenre() != null
? response.body().getSubsonicResponse().getSongsByGenre().getSongs()
: null;
}
if (songs != null) {
setChildrenMetadata(songs);
List<MediaItem> mediaItems = MappingUtil.mapMediaItems(songs);
LibraryResult<ImmutableList<MediaItem>> libraryResult = LibraryResult.ofItemList(ImmutableList.copyOf(mediaItems), null);
listenableFuture.set(libraryResult);
} else {
listenableFuture.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE));
}
} else {
listenableFuture.set(LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE));
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
listenableFuture.setException(t);
}
});
return listenableFuture;
}
public ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> getSongsByGenre(String genre, int count) {
return getSongsByGenre(genre, count, false);
}
private static class GetMediaItemThreadSafe implements Runnable { private static class GetMediaItemThreadSafe implements Runnable {
private final SessionMediaItemDao sessionMediaItemDao; private final SessionMediaItemDao sessionMediaItemDao;
private final String id; private final String id;

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,8 +26,45 @@ 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<>());
@@ -107,27 +144,62 @@ public class PlaylistRepository {
return playlistLiveData; return playlistLiveData;
} }
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) { public interface AddToPlaylistCallback {
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()) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show(); if (callback != null) callback.onAllSkipped();
} else{ } else{
App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getPlaylistClient() .getPlaylistClient()
.updatePlaylist(playlistId, null, true, songsId, null) .updatePlaylist(playlistId, null, playlistVisibilityIsPublic, 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) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show(); if (response.isSuccessful()) notifyPlaylistChanged();
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) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show(); if (callback != null) callback.onFailure();
} }
}); });
} }
} }
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()
@@ -135,7 +207,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
@@ -148,20 +220,45 @@ 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()
.deletePlaylist(playlistId) .updatePlaylist(playlistId, name, true, null, 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) {
createPlaylist(null, name, songsId); if (response.isSuccessful()) {
// 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()
@@ -169,7 +266,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
@@ -197,6 +294,49 @@ 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,88 +66,33 @@ public class PodcastRepository {
return liveNewestPodcastEpisodes; return liveNewestPodcastEpisodes;
} }
public void refreshPodcasts() { public Call<ApiResponse> refreshPodcasts() {
App.getSubsonicClientInstance(false) return App.getSubsonicClientInstance(false)
.getPodcastClient() .getPodcastClient()
.refreshPodcasts() .refreshPodcasts();
.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 createPodcastChannel(String url) { public Call<ApiResponse> createPodcastChannel(String url) {
App.getSubsonicClientInstance(false) return App.getSubsonicClientInstance(false)
.getPodcastClient() .getPodcastClient()
.createPodcastChannel(url) .createPodcastChannel(url);
.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 deletePodcastChannel(String channelId) { public Call<ApiResponse> deletePodcastChannel(String channelId) {
App.getSubsonicClientInstance(false) return App.getSubsonicClientInstance(false)
.getPodcastClient() .getPodcastClient()
.deletePodcastChannel(channelId) .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) { public Call<ApiResponse> deletePodcastEpisode(String episodeId) {
App.getSubsonicClientInstance(false) return App.getSubsonicClientInstance(false)
.getPodcastClient() .getPodcastClient()
.deletePodcastEpisode(episodeId) .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) { public Call<ApiResponse> downloadPodcastEpisode(String episodeId) {
App.getSubsonicClientInstance(false) return App.getSubsonicClientInstance(false)
.getPodcastClient() .getPodcastClient()
.downloadPodcastEpisode(episodeId) .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,8 +1,11 @@
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;
@@ -52,6 +55,8 @@ 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()
@@ -59,12 +64,19 @@ 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.setValue(response.body().getSubsonicResponse().getPlayQueue()); PlayQueue serverQueue = response.body().getSubsonicResponse().getPlayQueue();
Log.d(TAG, "Server returned play queue with " +
(serverQueue.getEntries() != null ? serverQueue.getEntries().size() : 0) + " items");
playQueue.setValue(serverQueue);
} else {
Log.d(TAG, "Server returned no play queue");
playQueue.setValue(null);
} }
} }
@Override @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);
} }
}); });
@@ -73,18 +85,24 @@ 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);
} }
}); });
} }
@@ -121,6 +139,14 @@ public class QueueRepository {
} }
} }
private boolean isMediaInQueue(List<Queue> queue, Child media) {
if (queue == null || media == null) return false;
return queue.stream().anyMatch(queueItem ->
queueItem != null && media.getId() != null &&
queueItem.getId().equals(media.getId())
);
}
public void insertAll(List<Child> toAdd, boolean reset, int afterIndex) { public void insertAll(List<Child> toAdd, boolean reset, int afterIndex) {
try { try {
List<Queue> media = new ArrayList<>(); List<Queue> media = new ArrayList<>();
@@ -134,8 +160,14 @@ public class QueueRepository {
media = getMediaThreadSafe.getMedia(); media = getMediaThreadSafe.getMedia();
} }
for (int i = 0; i < toAdd.size(); i++) { List<Child> filteredToAdd = toAdd;
Queue queueItem = new Queue(toAdd.get(i)); final List<Queue> finalMedia = media;
filteredToAdd = toAdd.stream()
.filter(child -> !isMediaInQueue(finalMedia, child))
.collect(Collectors.toList());
for (int i = 0; i < filteredToAdd.size(); i++) {
Queue queueItem = new Queue(filteredToAdd.get(i));
media.add(afterIndex + i, queueItem); media.add(afterIndex + i, queueItem);
} }

View File

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

@@ -1,9 +1,14 @@
package com.cappielloantonio.tempo.repository; package com.cappielloantonio.tempo.repository;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
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.database.AppDatabase; import com.cappielloantonio.tempo.database.AppDatabase;
import com.cappielloantonio.tempo.database.dao.RecentSearchDao; import com.cappielloantonio.tempo.database.dao.RecentSearchDao;
import com.cappielloantonio.tempo.model.RecentSearch; import com.cappielloantonio.tempo.model.RecentSearch;
@@ -11,12 +16,18 @@ 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.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.Playlist;
import com.cappielloantonio.tempo.subsonic.models.PlaylistWithSongs;
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 com.cappielloantonio.tempo.ui.fragment.SearchFragment;
import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.concurrent.Executors;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.Callback; import retrofit2.Callback;
@@ -30,7 +41,7 @@ public class SearchingRepository {
App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getSearchingClient() .getSearchingClient()
.search3(query, 20, 20, 20) .search3(query, 20, 0, 20, 0, 20, 0)
.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) {
@@ -48,12 +59,63 @@ public class SearchingRepository {
return result; return result;
} }
public MutableLiveData<SearchResult3> search3(String query) { @UnstableApi
public MutableLiveData<SearchResult3> search3(SearchFragment sf, String query) {
MutableLiveData<SearchResult3> result = new MutableLiveData<>(); MutableLiveData<SearchResult3> result = new MutableLiveData<>();
Executors.newSingleThreadExecutor().execute(() -> {
List<Child> allSongs = new ArrayList<>();
int offset = 0;
int limit = 1000;
boolean hasMore = true;
while (hasMore) {
try {
Response<ApiResponse> response = App.getSubsonicClientInstance(false)
.getSearchingClient()
.search3(query, limit, offset, 0, 0, 0, 0)
.execute();
if (response.isSuccessful() && response.body() != null) {
SearchResult3 tmp = response.body().getSubsonicResponse().getSearchResult3();
if (tmp != null && tmp.getSongs() != null && !tmp.getSongs().isEmpty()) {
List<Child> fetchedSongs = tmp.getSongs();
allSongs.addAll(fetchedSongs);
offset += fetchedSongs.size();
hasMore = fetchedSongs.size() == limit;
} else {
hasMore = false;
}
} else {
hasMore = false;
}
} catch (IOException e) {
e.printStackTrace();
hasMore = false;
}
}
PlaylistWithSongs pws = new PlaylistWithSongs("allsongs", allSongs);
pws.setName(sf.getView().getContext().getString(R.string.search_all_songs, String.valueOf(allSongs.size())));
pws.setSongCount(allSongs.size());
List<Playlist> lpws = new ArrayList<>();
lpws.add(pws);
long duration = 0;
for (Child song: allSongs) {
if (song != null && song.getDuration() != null) {
duration += song.getDuration();
}
}
pws.setDuration(duration);
new Handler(Looper.getMainLooper()).post(() -> {
sf.updateUI(lpws);
});
});
App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getSearchingClient() .getSearchingClient()
.search3(query, 20, 20, 20) .search3(query, 20, 0, 20, 0, 20, 0)
.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) {
@@ -76,7 +138,7 @@ public class SearchingRepository {
App.getSubsonicClientInstance(false) App.getSubsonicClientInstance(false)
.getSearchingClient() .getSearchingClient()
.search3(query, 5, 5, 5) .search3(query, 5, 0, 5, 0, 5, 0)
.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) {
@@ -186,7 +248,12 @@ public class SearchingRepository {
@Override @Override
public void run() { public void run() {
recent = recentSearchDao.getRecent(); if(Preferences.isSearchSortingChronologicallyEnabled()){
recent = recentSearchDao.getRecent();
}
else {
recent = recentSearchDao.getAlpha();
}
} }
public List<String> getRecent() { public List<String> getRecent() {

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.getSubsonicClientInstance(false) App.getSubsonicPublicClientInstance(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.getSubsonicClientInstance(false) App.getSubsonicPublicClientInstance(false)
.getSharingClient() .getSharingClient()
.updateShare(id, description, expires) .updateShare(id, description, expires)
.enqueue(new Callback<ApiResponse>() { .enqueue(new Callback<ApiResponse>() {

View File

@@ -1,23 +1,35 @@
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());
@@ -42,25 +54,202 @@ 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()
.getSimilarSongs2(id, count) .getSimilarSongs(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().getSimilarSongs2() != null) { if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSimilarSongs() != null) {
instantMix.setValue(response.body().getSubsonicResponse().getSimilarSongs2().getSongs()); instantMix.setValue(response.body().getSubsonicResponse().getSimilarSongs().getSongs());
} }
} }
@@ -73,161 +262,128 @@ 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>() {
App.getSubsonicClientInstance(false) @Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
.getAlbumSongListClient() List<Child> songs = new ArrayList<>();
.getRandomSongs(number, fromYear, toYear) if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null) {
.enqueue(new Callback<ApiResponse>() { List<Child> returned = response.body().getSubsonicResponse().getRandomSongs().getSongs();
@Override if (returned != null) {
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { songs.addAll(returned);
List<Child> songs = new ArrayList<>();
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null && response.body().getSubsonicResponse().getRandomSongs().getSongs() != null) {
songs.addAll(response.body().getSubsonicResponse().getRandomSongs().getSongs());
}
randomSongsSample.setValue(songs);
} }
}
randomSongsSample.setValue(songs);
}
@Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
});
return randomSongsSample;
}
@Override public MutableLiveData<List<Child>> getRandomSampleWithGenre(int number, Integer fromYear, Integer toYear, String genre) {
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) { MutableLiveData<List<Child>> randomSongsSample = new MutableLiveData<>();
App.getSubsonicClientInstance(false).getAlbumSongListClient().getRandomSongs(number, fromYear, toYear, genre).enqueue(new Callback<ApiResponse>() {
@Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
List<Child> songs = new ArrayList<>();
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getRandomSongs() != null) {
List<Child> returned = response.body().getSubsonicResponse().getRandomSongs().getSongs();
if (returned != null) {
songs.addAll(returned);
} }
}); }
randomSongsSample.setValue(songs);
}
@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) App.getSubsonicClientInstance(false).getMediaAnnotationClient().scrobble(id, submission).enqueue(new Callback<ApiResponse>() {
.getMediaAnnotationClient() @Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {}
.scrobble(id, submission) @Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
.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) App.getSubsonicClientInstance(false).getMediaAnnotationClient().setRating(id, rating).enqueue(new Callback<ApiResponse>() {
.getMediaAnnotationClient() @Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {}
.setRating(id, rating) @Override public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {}
.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>() {
App.getSubsonicClientInstance(false) @Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
.getAlbumSongListClient() if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) {
.getSongsByGenre(id, 100, 100 * page) songsByGenre.setValue(response.body().getSubsonicResponse().getSongsByGenre().getSongs());
.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) {}
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) { });
songsByGenre.setValue(response.body().getSubsonicResponse().getSongsByGenre().getSongs());
}
}
@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) {
for (String id : genresId) App.getSubsonicClientInstance(false).getAlbumSongListClient().getSongsByGenre(id, 500, 0).enqueue(new Callback<ApiResponse>() {
App.getSubsonicClientInstance(false) @Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
.getAlbumSongListClient() List<Child> songs = new ArrayList<>();
.getSongsByGenre(id, 500, 0) if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) {
.enqueue(new Callback<ApiResponse>() { List<Child> returned = response.body().getSubsonicResponse().getSongsByGenre().getSongs();
@Override if (returned != null) {
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { songs.addAll(returned);
List<Child> songs = new ArrayList<>();
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getSongsByGenre() != null) {
songs.addAll(response.body().getSubsonicResponse().getSongsByGenre().getSongs());
}
songsByGenre.setValue(songs);
} }
}
@Override songsByGenre.setValue(songs);
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>() {
App.getSubsonicClientInstance(false) @Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
.getBrowsingClient() if (response.isSuccessful() && response.body() != null) {
.getSong(id) song.setValue(response.body().getSubsonicResponse().getSong());
.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) {}
if (response.isSuccessful() && response.body() != null) { });
song.setValue(response.body().getSubsonicResponse().getSong());
}
}
@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>() {
App.getSubsonicClientInstance(false) @Override public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
.getMediaRetrievalClient() if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getLyrics() != null) {
.getLyrics(song.getArtist(), song.getTitle()) lyrics.setValue(response.body().getSubsonicResponse().getLyrics().getValue());
.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) {}
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getLyrics() != null) { });
lyrics.setValue(response.body().getSubsonicResponse().getLyrics().getValue());
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
return lyrics; return lyrics;
} }
} }

View File

@@ -0,0 +1,864 @@
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,12 +1,13 @@
package com.cappielloantonio.tempo.service; package com.cappielloantonio.tempo.service;
import android.content.ComponentName; import android.content.ComponentName;
import android.os.Handler;
import android.os.Looper;
import android.util.Log; 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;
@@ -25,6 +26,7 @@ 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;
@@ -36,10 +38,16 @@ import com.google.common.util.concurrent.MoreExecutors;
import java.lang.ref.WeakReference; import java.lang.ref.WeakReference;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
public class MediaManager { public class MediaManager {
private static final String TAG = "MediaManager"; private static final String TAG = "MediaManager";
private static WeakReference<MediaBrowser> attachedBrowserRef = new WeakReference<>(null); private static WeakReference<MediaBrowser> attachedBrowserRef = new WeakReference<>(null);
public static AtomicBoolean justStarted = new AtomicBoolean(false);
private static final ExecutorService backgroundExecutor = Executors.newSingleThreadExecutor();
public static void registerPlaybackObserver( public static void registerPlaybackObserver(
ListenableFuture<MediaBrowser> browserFuture, ListenableFuture<MediaBrowser> browserFuture,
@@ -173,33 +181,46 @@ public class MediaManager {
} }
} }
@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()) {
MediaBrowser browser = mediaBrowserListenableFuture.get(); final MediaBrowser browser = mediaBrowserListenableFuture.get();
browser.clearMediaItems(); final List<MediaItem> items = MappingUtil.mapMediaItems(media);
browser.setMediaItems(MappingUtil.mapMediaItems(media));
browser.prepare(); new Handler(Looper.getMainLooper()).post(() -> {
justStarted.set(true);
browser.setMediaItems(items, startIndex, 0);
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();
if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) { int itemCount = browser.getMediaItemCount();
browser.seekTo(startIndex, 0); if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) {
browser.play(); browser.seekTo(startIndex, 0);
browser.removeListener(this); browser.play();
browser.removeListener(this);
} else {
Log.d(TAG, "Cannot start playback: itemCount=" + itemCount + ", startIndex=" + startIndex);
}
} }
} };
};
browser.addListener(timelineListener); browser.addListener(timelineListener);
});
enqueueDatabase(media, true, 0); backgroundExecutor.execute(() -> {
Log.d(TAG, "Background: enqueuing to database");
enqueueDatabase(media, true, 0);
});
} }
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
e.printStackTrace(); Log.e(TAG, "Error in startQueue: " + e.getMessage(), e);
} }
}, MoreExecutors.directExecutor()); }, MoreExecutors.directExecutor());
} }
@@ -210,10 +231,11 @@ public class MediaManager {
mediaBrowserListenableFuture.addListener(() -> { mediaBrowserListenableFuture.addListener(() -> {
try { try {
if (mediaBrowserListenableFuture.isDone()) { if (mediaBrowserListenableFuture.isDone()) {
mediaBrowserListenableFuture.get().clearMediaItems(); MediaBrowser browser = mediaBrowserListenableFuture.get();
mediaBrowserListenableFuture.get().setMediaItem(MappingUtil.mapMediaItem(media)); justStarted.set(true);
mediaBrowserListenableFuture.get().prepare(); browser.setMediaItem(MappingUtil.mapMediaItem(media));
mediaBrowserListenableFuture.get().play(); browser.prepare();
browser.play();
enqueueDatabase(media, true, 0); enqueueDatabase(media, true, 0);
} }
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
@@ -229,7 +251,7 @@ public class MediaManager {
try { try {
if (mediaBrowserListenableFuture.isDone()) { if (mediaBrowserListenableFuture.isDone()) {
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get(); MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
mediaBrowser.clearMediaItems(); justStarted.set(true);
mediaBrowser.setMediaItem(mediaItem); mediaBrowser.setMediaItem(mediaItem);
mediaBrowser.prepare(); mediaBrowser.prepare();
mediaBrowser.play(); mediaBrowser.play();
@@ -247,10 +269,11 @@ public class MediaManager {
mediaBrowserListenableFuture.addListener(() -> { mediaBrowserListenableFuture.addListener(() -> {
try { try {
if (mediaBrowserListenableFuture.isDone()) { if (mediaBrowserListenableFuture.isDone()) {
mediaBrowserListenableFuture.get().clearMediaItems(); MediaBrowser browser = mediaBrowserListenableFuture.get();
mediaBrowserListenableFuture.get().setMediaItem(MappingUtil.mapInternetRadioStation(internetRadioStation)); justStarted.set(true);
mediaBrowserListenableFuture.get().prepare(); browser.setMediaItem(MappingUtil.mapInternetRadioStation(internetRadioStation));
mediaBrowserListenableFuture.get().play(); browser.prepare();
browser.play();
} }
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
e.printStackTrace(); e.printStackTrace();
@@ -264,10 +287,11 @@ public class MediaManager {
mediaBrowserListenableFuture.addListener(() -> { mediaBrowserListenableFuture.addListener(() -> {
try { try {
if (mediaBrowserListenableFuture.isDone()) { if (mediaBrowserListenableFuture.isDone()) {
mediaBrowserListenableFuture.get().clearMediaItems(); MediaBrowser browser = mediaBrowserListenableFuture.get();
mediaBrowserListenableFuture.get().setMediaItem(MappingUtil.mapMediaItem(podcastEpisode)); justStarted.set(true);
mediaBrowserListenableFuture.get().prepare(); browser.setMediaItem(MappingUtil.mapMediaItem(podcastEpisode));
mediaBrowserListenableFuture.get().play(); browser.prepare();
browser.play();
} }
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
e.printStackTrace(); e.printStackTrace();
@@ -281,9 +305,11 @@ public class MediaManager {
mediaBrowserListenableFuture.addListener(() -> { mediaBrowserListenableFuture.addListener(() -> {
try { try {
if (mediaBrowserListenableFuture.isDone()) { if (mediaBrowserListenableFuture.isDone()) {
if (playImmediatelyAfter && mediaBrowserListenableFuture.get().getNextMediaItemIndex() != -1) { Log.e(TAG, "enqueue");
enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getNextMediaItemIndex()); MediaBrowser browser = mediaBrowserListenableFuture.get();
mediaBrowserListenableFuture.get().addMediaItems(mediaBrowserListenableFuture.get().getNextMediaItemIndex(), MappingUtil.mapMediaItems(media)); if (playImmediatelyAfter && browser.getNextMediaItemIndex() != -1) {
enqueueDatabase(media, false, browser.getNextMediaItemIndex());
browser.addMediaItems(browser.getNextMediaItemIndex(), MappingUtil.mapMediaItems(media));
} else { } else {
enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getMediaItemCount()); enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getMediaItemCount());
mediaBrowserListenableFuture.get().addMediaItems(MappingUtil.mapMediaItems(media)); mediaBrowserListenableFuture.get().addMediaItems(MappingUtil.mapMediaItems(media));
@@ -301,9 +327,11 @@ public class MediaManager {
mediaBrowserListenableFuture.addListener(() -> { mediaBrowserListenableFuture.addListener(() -> {
try { try {
if (mediaBrowserListenableFuture.isDone()) { if (mediaBrowserListenableFuture.isDone()) {
if (playImmediatelyAfter && mediaBrowserListenableFuture.get().getNextMediaItemIndex() != -1) { Log.e(TAG, "enqueue");
enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getNextMediaItemIndex()); MediaBrowser browser = mediaBrowserListenableFuture.get();
mediaBrowserListenableFuture.get().addMediaItem(mediaBrowserListenableFuture.get().getNextMediaItemIndex(), MappingUtil.mapMediaItem(media)); if (playImmediatelyAfter && browser.getNextMediaItemIndex() != -1) {
enqueueDatabase(media, false, browser.getNextMediaItemIndex());
browser.addMediaItem(browser.getNextMediaItemIndex(), MappingUtil.mapMediaItem(media));
} else { } else {
enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getMediaItemCount()); enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getMediaItemCount());
mediaBrowserListenableFuture.get().addMediaItem(MappingUtil.mapMediaItem(media)); mediaBrowserListenableFuture.get().addMediaItem(MappingUtil.mapMediaItem(media));
@@ -321,8 +349,10 @@ public class MediaManager {
mediaBrowserListenableFuture.addListener(() -> { mediaBrowserListenableFuture.addListener(() -> {
try { try {
if (mediaBrowserListenableFuture.isDone()) { if (mediaBrowserListenableFuture.isDone()) {
mediaBrowserListenableFuture.get().removeMediaItems(startIndex, endIndex + 1); Log.e(TAG, "shuffle");
mediaBrowserListenableFuture.get().addMediaItems(MappingUtil.mapMediaItems(media).subList(startIndex, endIndex + 1)); MediaBrowser browser = mediaBrowserListenableFuture.get();
browser.removeMediaItems(startIndex, endIndex + 1);
browser.addMediaItems(MappingUtil.mapMediaItems(media).subList(startIndex, endIndex + 1));
swapDatabase(media); swapDatabase(media);
} }
} catch (ExecutionException | InterruptedException e) { } catch (ExecutionException | InterruptedException e) {
@@ -337,6 +367,7 @@ public class MediaManager {
mediaBrowserListenableFuture.addListener(() -> { mediaBrowserListenableFuture.addListener(() -> {
try { try {
if (mediaBrowserListenableFuture.isDone()) { if (mediaBrowserListenableFuture.isDone()) {
Log.e(TAG, "swap");
mediaBrowserListenableFuture.get().moveMediaItem(from, to); mediaBrowserListenableFuture.get().moveMediaItem(from, to);
swapDatabase(media); swapDatabase(media);
} }
@@ -352,6 +383,7 @@ public class MediaManager {
mediaBrowserListenableFuture.addListener(() -> { mediaBrowserListenableFuture.addListener(() -> {
try { try {
if (mediaBrowserListenableFuture.isDone()) { if (mediaBrowserListenableFuture.isDone()) {
Log.e(TAG, "remove");
if (mediaBrowserListenableFuture.get().getMediaItemCount() > 1 && mediaBrowserListenableFuture.get().getCurrentMediaItemIndex() != toRemove) { if (mediaBrowserListenableFuture.get().getMediaItemCount() > 1 && mediaBrowserListenableFuture.get().getCurrentMediaItemIndex() != toRemove) {
mediaBrowserListenableFuture.get().removeMediaItem(toRemove); mediaBrowserListenableFuture.get().removeMediaItem(toRemove);
removeDatabase(media, toRemove); removeDatabase(media, toRemove);
@@ -371,6 +403,7 @@ public class MediaManager {
mediaBrowserListenableFuture.addListener(() -> { mediaBrowserListenableFuture.addListener(() -> {
try { try {
if (mediaBrowserListenableFuture.isDone()) { if (mediaBrowserListenableFuture.isDone()) {
Log.e(TAG, "remove range");
mediaBrowserListenableFuture.get().removeMediaItems(fromItem, toItem); mediaBrowserListenableFuture.get().removeMediaItems(fromItem, toItem);
removeRangeDatabase(media, fromItem, toItem); removeRangeDatabase(media, fromItem, toItem);
} }
@@ -411,27 +444,33 @@ public class MediaManager {
} }
@OptIn(markerClass = UnstableApi.class) @OptIn(markerClass = UnstableApi.class)
public static void continuousPlay(MediaItem mediaItem) { public static void continuousPlay(MediaItem mediaItem,
if (mediaItem != null && Preferences.isContinuousPlayEnabled() && Preferences.isInstantMixUsable()) { ListenableFuture<MediaBrowser> existingBrowserFuture) {
Preferences.setLastInstantMix(); if (mediaItem == null
|| !Preferences.isContinuousPlayEnabled()
LiveData<List<Child>> instantMix = getSongRepository().getInstantMix(mediaItem.mediaId, 10); || !Preferences.isInstantMixUsable()) {
instantMix.observeForever(new Observer<List<Child>>() { return;
@Override
public void onChanged(List<Child> media) {
if (media != null) {
ListenableFuture<MediaBrowser> mediaBrowserListenableFuture = new MediaBrowser.Builder(
App.getContext(),
new SessionToken(App.getContext(), new ComponentName(App.getContext(), MediaService.class))
).buildAsync();
enqueue(mediaBrowserListenableFuture, media, true);
}
instantMix.removeObserver(this);
}
});
} }
Preferences.setLastInstantMix();
LiveData<List<Child>> instantMix =
getSongRepository().getContinuousMix(mediaItem.mediaId, 25);
instantMix.observeForever(new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> media) {
if (media == null || media.isEmpty()) {
return;
}
if (existingBrowserFuture != null) {
Log.d(TAG, "Continuous play: adding " + media.size() + " tracks");
enqueue(existingBrowserFuture, media, true);
}
instantMix.removeObserver(this);
}
});
} }
public static void saveChronology(MediaItem mediaItem) { public static void saveChronology(MediaItem mediaItem) {

View File

@@ -3,6 +3,7 @@ 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
@@ -13,7 +14,7 @@ import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class RetrofitClient(subsonic: Subsonic) { class RetrofitClient(subsonic: Subsonic) {
var retrofit: Retrofit val retrofit: Retrofit
init { init {
val gson = GsonBuilder() val gson = GsonBuilder()
@@ -50,6 +51,7 @@ class RetrofitClient(subsonic: Subsonic) {
.addInterceptor(cacheUtil.offlineInterceptor) .addInterceptor(cacheUtil.offlineInterceptor)
// .addNetworkInterceptor(cacheUtil.onlineInterceptor) // .addNetworkInterceptor(cacheUtil.onlineInterceptor)
.cache(getCache()) .cache(getCache())
.setupSsl()
.build() .build()
} }
@@ -63,4 +65,11 @@ 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

@@ -7,7 +7,7 @@ import java.util.UUID;
public class SubsonicPreferences { public class SubsonicPreferences {
private String serverUrl; private String serverUrl;
private String username; private String username;
private String clientName = "Tempo"; private String clientName = "Tempus";
private SubsonicAuthentication authentication; private SubsonicAuthentication authentication;
public String getServerUrl() { public String getServerUrl() {

View File

@@ -34,6 +34,11 @@ public class AlbumSongListClient {
return albumSongListService.getRandomSongs(subsonic.getParams(), size, fromYear, toYear); return albumSongListService.getRandomSongs(subsonic.getParams(), size, fromYear, toYear);
} }
public Call<ApiResponse> getRandomSongs(int size, Integer fromYear, Integer toYear, String genre) {
Log.d(TAG, "getRandomSongs()");
return albumSongListService.getRandomSongs(subsonic.getParams(), size, fromYear, toYear, genre);
}
public Call<ApiResponse> getSongsByGenre(String genre, int count, int offset) { public Call<ApiResponse> getSongsByGenre(String genre, int count, int offset) {
Log.d(TAG, "getSongsByGenre()"); Log.d(TAG, "getSongsByGenre()");
return albumSongListService.getSongsByGenre(subsonic.getParams(), genre, count, offset); return albumSongListService.getSongsByGenre(subsonic.getParams(), genre, count, offset);

View File

@@ -19,6 +19,9 @@ public interface AlbumSongListService {
@GET("getRandomSongs") @GET("getRandomSongs")
Call<ApiResponse> getRandomSongs(@QueryMap Map<String, String> params, @Query("size") int size, @Query("fromYear") Integer fromYear, @Query("toYear") Integer toYear); Call<ApiResponse> getRandomSongs(@QueryMap Map<String, String> params, @Query("size") int size, @Query("fromYear") Integer fromYear, @Query("toYear") Integer toYear);
@GET("getRandomSongs")
Call<ApiResponse> getRandomSongs(@QueryMap Map<String, String> params, @Query("size") int size, @Query("fromYear") Integer fromYear, @Query("toYear") Integer toYear, @Query("genre") String genre);
@GET("getSongsByGenre") @GET("getSongsByGenre")
Call<ApiResponse> getSongsByGenre(@QueryMap Map<String, String> params, @Query("genre") String genre, @Query("count") int count, @Query("offset") int offset); Call<ApiResponse> getSongsByGenre(@QueryMap Map<String, String> params, @Query("genre") String genre, @Query("count") int count, @Query("offset") int offset);

View File

@@ -24,8 +24,8 @@ public class SearchingClient {
return searchingService.search2(subsonic.getParams(), query, songCount, albumCount, artistCount); return searchingService.search2(subsonic.getParams(), query, songCount, albumCount, artistCount);
} }
public Call<ApiResponse> search3(String query, int songCount, int albumCount, int artistCount) { public Call<ApiResponse> search3(String query, int songCount, int songOffset, int albumCount, int albumOffset, int artistCount, int artistOffset) {
Log.d(TAG, "search3()"); Log.d(TAG, "search3()");
return searchingService.search3(subsonic.getParams(), query, songCount, albumCount, artistCount); return searchingService.search3(subsonic.getParams(), query, songCount, songOffset, albumCount, albumOffset, artistCount, artistOffset);
} }
} }

View File

@@ -14,5 +14,5 @@ public interface SearchingService {
Call<ApiResponse> search2(@QueryMap Map<String, String> params, @Query("query") String query, @Query("songCount") int songCount, @Query("albumCount") int albumCount, @Query("artistCount") int artistCount); Call<ApiResponse> search2(@QueryMap Map<String, String> params, @Query("query") String query, @Query("songCount") int songCount, @Query("albumCount") int albumCount, @Query("artistCount") int artistCount);
@GET("search3") @GET("search3")
Call<ApiResponse> search3(@QueryMap Map<String, String> params, @Query("query") String query, @Query("songCount") int songCount, @Query("albumCount") int albumCount, @Query("artistCount") int artistCount); Call<ApiResponse> search3(@QueryMap Map<String, String> params, @Query("query") String query, @Query("songCount") int songCount, @Query("songOffset") int songOffset, @Query("albumCount") int albumCount, @Query("albumOffset") int albumOffset, @Query("artistCount") int artistCount, @Query("artistOffset") int artistOffset);
} }

View File

@@ -24,13 +24,15 @@ 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(1, TimeUnit.SECONDS); .timeout(timeoutSeconds, TimeUnit.SECONDS);
} else { } else {
int finalTimeout = Math.min(timeoutSeconds * 2, 10);
pingCall.timeout() pingCall.timeout()
.timeout(3, TimeUnit.SECONDS); .timeout(finalTimeout, TimeUnit.SECONDS);
} }
return pingCall; return pingCall;
} }

View File

@@ -3,6 +3,7 @@ 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
@@ -10,5 +11,6 @@ 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,6 +22,7 @@ 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,8 +1,10 @@
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,7 +62,8 @@ 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,27 +3,27 @@ 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.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.text.TextUtils; import android.text.TextUtils;
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.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.Player;
import androidx.media3.common.MimeTypes; import androidx.media3.common.MimeTypes;
import androidx.media3.common.Player;
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;
import androidx.navigation.ui.NavigationUI;
import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.BuildConfig; import com.cappielloantonio.tempo.BuildConfig;
@@ -31,8 +31,12 @@ import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.broadcast.receiver.ConnectivityStatusBroadcastReceiver; import com.cappielloantonio.tempo.broadcast.receiver.ConnectivityStatusBroadcastReceiver;
import com.cappielloantonio.tempo.databinding.ActivityMainBinding; import com.cappielloantonio.tempo.databinding.ActivityMainBinding;
import com.cappielloantonio.tempo.github.utils.UpdateUtil; import com.cappielloantonio.tempo.github.utils.UpdateUtil;
import com.cappielloantonio.tempo.navigation.NavigationController;
import com.cappielloantonio.tempo.navigation.NavigationHelper;
import com.cappielloantonio.tempo.service.MediaManager; import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.ui.activity.base.BaseActivity; import com.cappielloantonio.tempo.ui.activity.base.BaseActivity;
import com.cappielloantonio.tempo.ui.controller.BottomSheetController;
import com.cappielloantonio.tempo.ui.controller.BottomSheetHelper;
import com.cappielloantonio.tempo.ui.dialog.ConnectionAlertDialog; import com.cappielloantonio.tempo.ui.dialog.ConnectionAlertDialog;
import com.cappielloantonio.tempo.ui.dialog.GithubTempoUpdateDialog; import com.cappielloantonio.tempo.ui.dialog.GithubTempoUpdateDialog;
import com.cappielloantonio.tempo.ui.dialog.ServerUnreachableDialog; import com.cappielloantonio.tempo.ui.dialog.ServerUnreachableDialog;
@@ -45,6 +49,7 @@ 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;
@@ -60,14 +65,24 @@ 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;
private DrawerLayout drawerLayout;
private NavigationView navigationView;
public NavController navController; public NavController navController;
private BottomSheetBehavior bottomSheetBehavior; private NavigationController navigationController;
private BottomSheetController bottomSheetController;
public BottomSheetBehavior bottomSheetBehavior;
public boolean isLandscape = false;
private AssetLinkNavigator assetLinkNavigator; private AssetLinkNavigator assetLinkNavigator;
private AssetLinkUtil.AssetLink pendingAssetLink; private AssetLinkUtil.AssetLink pendingAssetLink;
ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver; ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver;
private Intent pendingDownloadPlaybackIntent; private Intent pendingDownloadPlaybackIntent;
public ActivityMainBinding getBinding() {
return bind;
}
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
SplashScreen.installSplashScreen(this); SplashScreen.installSplashScreen(this);
@@ -85,6 +100,8 @@ 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();
@@ -105,6 +122,7 @@ public class MainActivity extends BaseActivity {
protected void onResume() { protected void onResume() {
super.onResume(); super.onResume();
pingServer(); pingServer();
toggleNavigationDrawerLockOnOrientationChange();
} }
@Override @Override
@@ -131,7 +149,6 @@ public class MainActivity extends BaseActivity {
} }
public void init() { public void init() {
fragmentManager = getSupportFragmentManager();
initBottomSheet(); initBottomSheet();
initNavigation(); initNavigation();
@@ -141,51 +158,79 @@ public class MainActivity extends BaseActivity {
} else { } else {
goToLogin(); goToLogin();
} }
toggleNavigationDrawerLockOnOrientationChange();
} }
// BOTTOM SHEET/NAVIGATION private void initNavigation() {
private void initBottomSheet() { // We link the nav_graph.xml with our navigationController
bottomSheetBehavior = BottomSheetBehavior.from(findViewById(R.id.player_bottom_sheet)); NavHostFragment navHostFragment = (NavHostFragment) this
bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback); .getSupportFragmentManager()
fragmentManager.beginTransaction().replace(R.id.player_bottom_sheet, new PlayerBottomSheetFragment(), "PlayerBottomSheet").commit(); .findFragmentById(R.id.nav_host_fragment);
navController = Objects.requireNonNull(navHostFragment).getNavController();
/*
navController is currently global since some legacy code still invokes it directly
the MainActivity methods that use it must be converted to NavigationHelper methods
*/
checkBottomSheetAfterStateChanged(); // Helper
NavigationHelper navigationHelper =
new NavigationHelper(
findViewById(R.id.bottom_navigation),
findViewById(R.id.bottom_navigation_frame),
findViewById(R.id.drawer_layout),
findViewById(R.id.nav_view),
navHostFragment
);
// Controller
navigationController = new NavigationController(navigationHelper);
navigationController.syncWithBottomSheetBehavior(bottomSheetBehavior, navController);
}
private void initBottomSheet() {
FragmentManager fragmentManager = getSupportFragmentManager();
View bottomSheetView = findViewById(R.id.player_bottom_sheet);
bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetView);
/*
bottomSheetBehavior is currently global since some legacy code still invokes it directly
the MainActivity methods that use it must be converted to BottomSheetHelper methods
*/
// Helper
BottomSheetHelper bottomSheetHelper =
new BottomSheetHelper(
bottomSheetBehavior,
bottomSheetView,
fragmentManager
);
// Controller
bottomSheetController = new BottomSheetController(bottomSheetHelper);
bottomSheetController.addCallback(bottomSheetCallback);
bottomSheetController.replaceFragment(R.id.player_bottom_sheet);
bottomSheetController.checkAfterStateChanged(mainViewModel);
} }
public void setBottomSheetInPeek(Boolean isVisible) { public void setBottomSheetInPeek(Boolean isVisible) {
if (isVisible) { bottomSheetController.setStateInPeek(isVisible);
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
} else {
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
}
} }
public void setBottomSheetVisibility(boolean visibility) { public void setBottomSheetVisibility(boolean visibility) {
if (visibility) { bottomSheetController.setVisibility(visibility);
findViewById(R.id.player_bottom_sheet).setVisibility(View.VISIBLE);
} else {
findViewById(R.id.player_bottom_sheet).setVisibility(View.GONE);
}
}
private void checkBottomSheetAfterStateChanged() {
final Handler handler = new Handler();
final Runnable runnable = () -> setBottomSheetInPeek(mainViewModel.isQueueLoaded());
handler.postDelayed(runnable, 100);
} }
public void collapseBottomSheetDelayed() { public void collapseBottomSheetDelayed() {
final Handler handler = new Handler(); bottomSheetController.collapseDelayed();
final Runnable runnable = () -> bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
handler.postDelayed(runnable, 100);
} }
public void expandBottomSheet() { public void expandBottomSheet() {
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); bottomSheetController.expand();
} }
public void setBottomSheetDraggableState(Boolean isDraggable) { public void setBottomSheetDraggableState(Boolean isDraggable) {
bottomSheetBehavior.setDraggable(isDraggable); bottomSheetController.setDraggable(isDraggable);
} }
private final BottomSheetBehavior.BottomSheetCallback bottomSheetCallback = private final BottomSheetBehavior.BottomSheetCallback bottomSheetCallback =
@@ -198,7 +243,7 @@ public class MainActivity extends BaseActivity {
switch (state) { switch (state) {
case BottomSheetBehavior.STATE_HIDDEN: case BottomSheetBehavior.STATE_HIDDEN:
resetMusicSession(); resetMusicSession(); // I can't put the callback inside BottomSheetHelper because of this line
break; break;
case BottomSheetBehavior.STATE_COLLAPSED: case BottomSheetBehavior.STATE_COLLAPSED:
if (playerBottomSheetFragment != null) if (playerBottomSheetFragment != null)
@@ -215,17 +260,14 @@ 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);
animateBottomNavigation(slideOffset, navigationHeight); if (!isLandscape) {
animateBottomNavigation(slideOffset, navigationHeight);
}
} }
}; };
private void animateBottomSheet(float slideOffset) { private void animateBottomSheet(float slideOffset) {
PlayerBottomSheetFragment playerBottomSheetFragment = (PlayerBottomSheetFragment) getSupportFragmentManager().findFragmentByTag("PlayerBottomSheet"); bottomSheetController.animate(slideOffset);
if (playerBottomSheetFragment != null) {
float condensedSlideOffset = Math.max(0.0f, Math.min(0.2f, slideOffset - 0.2f)) / 0.2f;
playerBottomSheetFragment.getPlayerHeader().setAlpha(1 - condensedSlideOffset);
playerBottomSheetFragment.getPlayerHeader().setVisibility(condensedSlideOffset > 0.99 ? View.GONE : View.VISIBLE);
}
} }
private void animateBottomNavigation(float slideOffset, int navigationHeight) { private void animateBottomNavigation(float slideOffset, int navigationHeight) {
@@ -240,36 +282,57 @@ public class MainActivity extends BaseActivity {
bind.bottomNavigation.setTranslationY(slideY); bind.bottomNavigation.setTranslationY(slideY);
} }
private void initNavigation() { public void setBottomNavigationBarVisibility(boolean visibility) {
bottomNavigationView = findViewById(R.id.bottom_navigation); navigationController.setNavbarVisibility(visibility);
navHostFragment = (NavHostFragment) fragmentManager.findFragmentById(R.id.nav_host_fragment);
navController = Objects.requireNonNull(navHostFragment).getNavController();
/*
* In questo modo intercetto il cambio schermata tramite navbar e se il bottom sheet è aperto,
* lo chiudo
*/
navController.addOnDestinationChangedListener((controller, destination, arguments) -> {
if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED && (
destination.getId() == R.id.homeFragment ||
destination.getId() == R.id.libraryFragment ||
destination.getId() == R.id.downloadFragment)
) {
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
});
NavigationUI.setupWithNavController(bottomNavigationView, navController);
} }
public void setBottomNavigationBarVisibility(boolean visibility) { public void toggleBottomNavigationBarVisibilityOnOrientationChange() {
if (visibility) { float displayDensity = getResources().getDisplayMetrics().density;
bottomNavigationView.setVisibility(View.VISIBLE); // Ignore orientation change, bottom navbar always hidden
if (Preferences.getHideBottomNavbarOnPortrait()) {
navigationController.setNavbarVisibility(false);
bottomSheetController.setPeekHeight(56, displayDensity);
navigationController.setSystemBarsVisibility(this, !isLandscape);
return;
}
if (!isLandscape) {
// Show app navbar + show system bars
bottomSheetController.setPeekHeight(136, displayDensity);
navigationController.setNavbarVisibility(true);
navigationController.setSystemBarsVisibility(this, true);
} else { } else {
bottomNavigationView.setVisibility(View.GONE); // Hide app navbar + hide system bars
bottomSheetController.setPeekHeight(56, displayDensity);
navigationController.setNavbarVisibility(false);
navigationController.setSystemBarsVisibility(this, false);
} }
} }
public void setNavigationDrawerLock(boolean locked) {
navigationController.setDrawerLock(locked);
}
public boolean isNavigationDrawerLocked() {
return navigationController.isNavigationDrawerLocked();
}
public void toggleNavigationDrawerLockOnOrientationChange() {
navigationController.toggleDrawerLockOnOrientation(this);
}
public void setSystemBarsVisibility(boolean visibility) {
navigationController.setSystemBarsVisibility(this, visibility);
}
/*
There are only 4 init functions that must exist up to here
1. init()
2. initNavigation()
3. initBottomSheet()
4. bottomSheetCallback = new BottomSheetBehavior.BottomSheetCallback() { ... }
*/
private void initService() { private void initService() {
MediaManager.check(getMediaBrowserListenableFuture()); MediaManager.check(getMediaBrowserListenableFuture());
@@ -304,7 +367,7 @@ public class MainActivity extends BaseActivity {
} }
private void goToHome() { private void goToHome() {
bottomNavigationView.setVisibility(View.VISIBLE); setBottomNavigationBarVisibility(true);
if (Objects.requireNonNull(navController.getCurrentDestination()).getId() == R.id.landingFragment) { if (Objects.requireNonNull(navController.getCurrentDestination()).getId() == R.id.landingFragment) {
navController.navigate(R.id.action_landingFragment_to_homeFragment); navController.navigate(R.id.action_landingFragment_to_homeFragment);
@@ -351,10 +414,11 @@ 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(Constants.MEDIA_PLAYBACK_SPEED_100); Preferences.setPlaybackSpeed(1.0f);
Preferences.setSkipSilenceMode(false); Preferences.setSkipSilenceMode(false);
Preferences.setDataSavingMode(false); Preferences.setDataSavingMode(false);
Preferences.setStarredSyncEnabled(false); Preferences.setStarredSyncEnabled(false);
@@ -384,7 +448,7 @@ public class MainActivity extends BaseActivity {
} }
private void pingServer() { private void pingServer() {
if (Preferences.getToken() == null) return; if (Preferences.getToken() == null && Preferences.getPassword() == null) return;
if (Preferences.isInUseServerAddressLocal()) { if (Preferences.isInUseServerAddressLocal()) {
mainViewModel.ping().observe(this, subsonicResponse -> { mainViewModel.ping().observe(this, subsonicResponse -> {
@@ -428,7 +492,7 @@ public class MainActivity extends BaseActivity {
} }
private void getOpenSubsonicExtensions() { private void getOpenSubsonicExtensions() {
if (Preferences.getToken() != null) { if (Preferences.getToken() != null || Preferences.getPassword() != null) {
mainViewModel.getOpenSubsonicExtensions().observe(this, openSubsonicExtensions -> { mainViewModel.getOpenSubsonicExtensions().observe(this, openSubsonicExtensions -> {
if (openSubsonicExtensions != null) { if (openSubsonicExtensions != null) {
Preferences.setOpenSubsonicExtensions(openSubsonicExtensions); Preferences.setOpenSubsonicExtensions(openSubsonicExtensions);
@@ -438,7 +502,7 @@ public class MainActivity extends BaseActivity {
} }
private void checkTempoUpdate() { private void checkTempoUpdate() {
if (BuildConfig.FLAVOR.equals("tempo") && Preferences.showTempoUpdateDialog()) { if (BuildConfig.FLAVOR.equals("tempus") && Preferences.isGithubUpdateEnabled() && Preferences.showTempusUpdateDialog()) {
mainViewModel.checkTempoUpdate().observe(this, latestRelease -> { mainViewModel.checkTempoUpdate().observe(this, latestRelease -> {
if (latestRelease != null && UpdateUtil.showUpdateDialog(latestRelease)) { if (latestRelease != null && UpdateUtil.showUpdateDialog(latestRelease)) {
GithubTempoUpdateDialog dialog = new GithubTempoUpdateDialog(latestRelease); GithubTempoUpdateDialog dialog = new GithubTempoUpdateDialog(latestRelease);

View File

@@ -13,6 +13,7 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3; import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
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.TileSizeManager;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@@ -22,6 +23,8 @@ public class AlbumAdapter extends RecyclerView.Adapter<AlbumAdapter.ViewHolder>
private List<AlbumID3> albums; private List<AlbumID3> albums;
private int sizePx = 400;
public AlbumAdapter(ClickCallback click) { public AlbumAdapter(ClickCallback click) {
this.click = click; this.click = click;
this.albums = Collections.emptyList(); this.albums = Collections.emptyList();
@@ -31,11 +34,20 @@ public class AlbumAdapter extends RecyclerView.Adapter<AlbumAdapter.ViewHolder>
@Override @Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
ItemLibraryAlbumBinding view = ItemLibraryAlbumBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); ItemLibraryAlbumBinding view = ItemLibraryAlbumBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
TileSizeManager.getInstance().calculateTileSize(parent.getContext());
sizePx = TileSizeManager.getInstance().getTileSizePx(parent.getContext());
return new ViewHolder(view); return new ViewHolder(view);
} }
@Override @Override
public void onBindViewHolder(ViewHolder holder, int position) { public void onBindViewHolder(ViewHolder holder, int position) {
ViewGroup.LayoutParams lp = holder.item.albumCoverImageView.getLayoutParams();
lp.width = sizePx;
lp.height = sizePx;
holder.item.albumCoverImageView.setLayoutParams(lp);
AlbumID3 album = albums.get(position); AlbumID3 album = albums.get(position);
holder.item.albumNameLabel.setText(album.getName()); holder.item.albumNameLabel.setText(album.getName());

View File

@@ -14,6 +14,7 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
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;
import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.TileSizeManager;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@@ -24,6 +25,7 @@ public class ArtistAdapter extends RecyclerView.Adapter<ArtistAdapter.ViewHolder
private final boolean mix; private final boolean mix;
private final boolean bestOf; private final boolean bestOf;
private int sizePx = 400;
private List<ArtistID3> artists; private List<ArtistID3> artists;
public ArtistAdapter(ClickCallback click, Boolean mix, Boolean bestOf) { public ArtistAdapter(ClickCallback click, Boolean mix, Boolean bestOf) {
@@ -37,11 +39,20 @@ public class ArtistAdapter extends RecyclerView.Adapter<ArtistAdapter.ViewHolder
@Override @Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
ItemLibraryArtistBinding view = ItemLibraryArtistBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); ItemLibraryArtistBinding view = ItemLibraryArtistBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
TileSizeManager.getInstance().calculateTileSize(parent.getContext());
sizePx = TileSizeManager.getInstance().getTileSizePx(parent.getContext());
return new ViewHolder(view); return new ViewHolder(view);
} }
@Override @Override
public void onBindViewHolder(ViewHolder holder, int position) { public void onBindViewHolder(ViewHolder holder, int position) {
ViewGroup.LayoutParams lp = holder.item.artistCoverImageView.getLayoutParams();
lp.width = sizePx;
lp.height = sizePx;
holder.item.artistCoverImageView.setLayoutParams(lp);
ArtistID3 artist = artists.get(position); ArtistID3 artist = artists.get(position);
holder.item.artistNameLabel.setText(artist.getName()); holder.item.artistNameLabel.setText(artist.getName());

View File

@@ -146,11 +146,14 @@ 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)); artists.sort(Comparator.comparing(ArtistID3::getName,String.CASE_INSENSITIVE_ORDER));
break; break;
case Constants.ARTIST_ORDER_BY_RANDOM: case Constants.ARTIST_ORDER_BY_RANDOM:
Collections.shuffle(artists); Collections.shuffle(artists);
break; break;
case Constants.ARTIST_ORDER_BY_ALBUM_COUNT:
artists.sort(Comparator.comparing(ArtistID3::getAlbumCount).reversed());
break;
} }
notifyDataSetChanged(); notifyDataSetChanged();

View File

@@ -13,6 +13,7 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.subsonic.models.SimilarArtistID3; import com.cappielloantonio.tempo.subsonic.models.SimilarArtistID3;
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.TileSizeManager;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@@ -22,6 +23,8 @@ public class ArtistSimilarAdapter extends RecyclerView.Adapter<ArtistSimilarAdap
private List<SimilarArtistID3> artists; private List<SimilarArtistID3> artists;
private int sizePx = 400;
public ArtistSimilarAdapter(ClickCallback click) { public ArtistSimilarAdapter(ClickCallback click) {
this.click = click; this.click = click;
this.artists = Collections.emptyList(); this.artists = Collections.emptyList();
@@ -31,11 +34,20 @@ public class ArtistSimilarAdapter extends RecyclerView.Adapter<ArtistSimilarAdap
@Override @Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
ItemLibrarySimilarArtistBinding view = ItemLibrarySimilarArtistBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); ItemLibrarySimilarArtistBinding view = ItemLibrarySimilarArtistBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
TileSizeManager.getInstance().calculateTileSize(parent.getContext());
sizePx = TileSizeManager.getInstance().getTileSizePx(parent.getContext());
return new ViewHolder(view); return new ViewHolder(view);
} }
@Override @Override
public void onBindViewHolder(ViewHolder holder, int position) { public void onBindViewHolder(ViewHolder holder, int position) {
ViewGroup.LayoutParams lp = holder.item.similarArtistCoverImageView.getLayoutParams();
lp.width = sizePx;
lp.height = sizePx;
holder.item.similarArtistCoverImageView.setLayoutParams(lp);
SimilarArtistID3 artist = artists.get(position); SimilarArtistID3 artist = artists.get(position);
holder.item.artistNameLabel.setText(artist.getName()); holder.item.artistNameLabel.setText(artist.getName());

View File

@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.adapter;
import android.os.Bundle; import android.os.Bundle;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.animation.AccelerateDecelerateInterpolator; import android.view.animation.AccelerateDecelerateInterpolator;
@@ -14,6 +15,7 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
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.TileSizeManager;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@@ -23,6 +25,9 @@ public class DiscoverSongAdapter extends RecyclerView.Adapter<DiscoverSongAdapte
private List<Child> songs; private List<Child> songs;
private int widthPx = 800;
private int heightPx = 400;
public DiscoverSongAdapter(ClickCallback click) { public DiscoverSongAdapter(ClickCallback click) {
this.click = click; this.click = click;
this.songs = Collections.emptyList(); this.songs = Collections.emptyList();
@@ -32,11 +37,21 @@ public class DiscoverSongAdapter extends RecyclerView.Adapter<DiscoverSongAdapte
@Override @Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
ItemHomeDiscoverSongBinding view = ItemHomeDiscoverSongBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); ItemHomeDiscoverSongBinding view = ItemHomeDiscoverSongBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
TileSizeManager.getInstance().calculateDiscoverSize(parent.getContext());
widthPx = TileSizeManager.getInstance().getDiscoverWidthPx(parent.getContext());;
heightPx = TileSizeManager.getInstance().getDiscoverHeightPx(parent.getContext());;
return new ViewHolder(view); return new ViewHolder(view);
} }
@Override @Override
public void onBindViewHolder(ViewHolder holder, int position) { public void onBindViewHolder(ViewHolder holder, int position) {
ViewGroup.LayoutParams lp = holder.item.discoverSongCoverImageView.getLayoutParams();
lp.width = widthPx;
lp.height = heightPx;
holder.item.discoverSongCoverImageView.setLayoutParams(lp);
Child song = songs.get(position); Child song = songs.get(position);
holder.item.titleDiscoverSongLabel.setText(song.getTitle()); holder.item.titleDiscoverSongLabel.setText(song.getTitle());
@@ -73,6 +88,11 @@ public class DiscoverSongAdapter extends RecyclerView.Adapter<DiscoverSongAdapte
this.item = item; this.item = item;
itemView.setOnClickListener(v -> onClick()); itemView.setOnClickListener(v -> onClick());
itemView.setOnLongClickListener(v -> {
onLongClick();
return true;
});
} }
public void onClick() { public void onClick() {
@@ -82,6 +102,13 @@ public class DiscoverSongAdapter extends RecyclerView.Adapter<DiscoverSongAdapte
click.onMediaClick(bundle); click.onMediaClick(bundle);
} }
private boolean onLongClick() {
Bundle bundle = new Bundle();
bundle.putParcelable(Constants.TRACK_OBJECT, songs.get(getBindingAdapterPosition()));
click.onMediaLongClick(bundle);
return true;
}
} }
private void startAnimation(ViewHolder holder) { private void startAnimation(ViewHolder holder) {

View File

@@ -42,8 +42,13 @@ 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(), internetRadioStation.getStreamUrl(), CustomGlideRequest.ResourceType.Radio) .from(holder.itemView.getContext(), imageId, CustomGlideRequest.ResourceType.Radio)
.build() .build()
.into(holder.item.internetRadioStationCoverImageView); .into(holder.item.internetRadioStationCoverImageView);
} }

View File

@@ -53,7 +53,7 @@ public class MusicDirectoryAdapter extends RecyclerView.Adapter<MusicDirectoryAd
.into(holder.item.musicDirectoryCoverImageView); .into(holder.item.musicDirectoryCoverImageView);
holder.item.musicDirectoryMoreButton.setVisibility(child.isDir() ? View.VISIBLE : View.INVISIBLE); holder.item.musicDirectoryMoreButton.setVisibility(child.isDir() ? View.VISIBLE : View.INVISIBLE);
holder.item.musicDirectoryPlayButton.setVisibility(child.isDir() ? View.INVISIBLE : View.VISIBLE); holder.item.musicDirectoryPlayButton.setVisibility(child.isDir() ? View.VISIBLE : View.INVISIBLE);
} }
@Override @Override
@@ -80,6 +80,7 @@ public class MusicDirectoryAdapter extends RecyclerView.Adapter<MusicDirectoryAd
itemView.setOnLongClickListener(v -> onLongClick()); itemView.setOnLongClickListener(v -> onLongClick());
item.musicDirectoryMoreButton.setOnClickListener(v -> onClick()); item.musicDirectoryMoreButton.setOnClickListener(v -> onClick());
item.musicDirectoryPlayButton.setOnClickListener(v -> onPlayClick());
} }
public void onClick() { public void onClick() {
@@ -107,5 +108,13 @@ public class MusicDirectoryAdapter extends RecyclerView.Adapter<MusicDirectoryAd
return false; return false;
} }
} }
public void onPlayClick() {
if (children.get(getBindingAdapterPosition()).isDir()) {
Bundle bundle = new Bundle();
bundle.putString(Constants.MUSIC_DIRECTORY_ID, children.get(getBindingAdapterPosition()).getId());
click.onMusicDirectoryPlay(bundle);
}
}
} }
} }

View File

@@ -76,6 +76,7 @@ public class MusicIndexAdapter extends RecyclerView.Adapter<MusicIndexAdapter.Vi
itemView.setOnClickListener(v -> onClick()); itemView.setOnClickListener(v -> onClick());
item.musicIndexMoreButton.setOnClickListener(v -> onClick()); item.musicIndexMoreButton.setOnClickListener(v -> onClick());
item.musicIndexPlayButton.setOnClickListener(v -> onPlayClick());
} }
public void onClick() { public void onClick() {
@@ -83,5 +84,11 @@ public class MusicIndexAdapter extends RecyclerView.Adapter<MusicIndexAdapter.Vi
bundle.putString(Constants.MUSIC_DIRECTORY_ID, artists.get(getBindingAdapterPosition()).getId()); bundle.putString(Constants.MUSIC_DIRECTORY_ID, artists.get(getBindingAdapterPosition()).getId());
click.onMusicIndexClick(bundle); click.onMusicIndexClick(bundle);
} }
public void onPlayClick() {
Bundle bundle = new Bundle();
bundle.putString(Constants.MUSIC_DIRECTORY_ID, artists.get(getBindingAdapterPosition()).getId());
click.onMusicIndexPlay(bundle);
}
} }
} }

View File

@@ -18,9 +18,12 @@ 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;
@@ -29,7 +32,9 @@ 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";
@@ -37,7 +42,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();
@@ -78,7 +83,6 @@ 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) {
@@ -94,6 +98,23 @@ 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);
@@ -153,7 +174,7 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
holder.item.coverArtOverlay.setVisibility(View.INVISIBLE); holder.item.coverArtOverlay.setVisibility(View.INVISIBLE);
} }
} }
public List<Child> getItems() { public List<Child> getItems() {
return this.songs; return this.songs;
} }

View File

@@ -47,6 +47,7 @@ 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;
} }
@@ -54,7 +55,9 @@ 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.count > 0) playlists.addAll((List) results.values); if (results.values != null) {
playlists.addAll((List<Playlist>) results.values);
}
notifyDataSetChanged(); notifyDataSetChanged();
} }
}; };

View File

@@ -173,10 +173,12 @@ 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) {
@@ -357,6 +359,7 @@ 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

@@ -0,0 +1,63 @@
package com.cappielloantonio.tempo.ui.controller;
import androidx.annotation.NonNull;
import com.cappielloantonio.tempo.viewmodel.MainViewModel;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
public class BottomSheetController {
BottomSheetHelper helper;
public BottomSheetController(@NonNull BottomSheetHelper bottomSheetPlayerHelper) {
this.helper = bottomSheetPlayerHelper;
}
public void expand() {
helper.setState(BottomSheetBehavior.STATE_EXPANDED);
}
public void hide() {
helper.setState(BottomSheetBehavior.STATE_HIDDEN);
}
public void setStateInPeek(boolean isVisible) {
helper.setStateInPeek(isVisible);
}
public void setVisibility(boolean visibility) {
helper.setVisibility(visibility);
}
public void addCallback(BottomSheetBehavior.BottomSheetCallback callback) {
helper.addCallback(callback);
}
public void replaceFragment(int playerBottomSheet) {
helper.replaceFragment(playerBottomSheet);
}
public void checkAfterStateChanged(MainViewModel mainViewModel) {
helper.checkAfterStateChanged(mainViewModel);
}
public void collapseDelayed() {
helper.collapseDelayed();
}
public void setDraggable(Boolean isDraggable) {
helper.setDraggable(isDraggable);
}
public int getState() {
return helper.getState();
}
public void animate(float slideOffset) {
helper.animate(slideOffset);
}
public void setPeekHeight(int peekHeight, float displayDensity) {
helper.setPeekHeight(peekHeight, displayDensity);
}
}

View File

@@ -0,0 +1,97 @@
package com.cappielloantonio.tempo.ui.controller;
import android.os.Handler;
import android.view.View;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.fragment.app.FragmentManager;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.ui.fragment.PlayerBottomSheetFragment;
import com.cappielloantonio.tempo.viewmodel.MainViewModel;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
public class BottomSheetHelper {
BottomSheetBehavior<View> bottomSheetBehavior;
View bottomSheetView;
FragmentManager fragmentManager; // Of the entire activity
PlayerBottomSheetFragment playerBottomSheetFragment;
public void setState(int state) {
bottomSheetBehavior.setState(state);
}
public BottomSheetHelper(@NonNull BottomSheetBehavior<View> bottomSheetBehavior,
@NonNull View bottomSheetView,
@NonNull FragmentManager fragmentManager) {
this.bottomSheetBehavior = bottomSheetBehavior;
this.bottomSheetView = bottomSheetView;
this.fragmentManager = fragmentManager;
this.playerBottomSheetFragment = new PlayerBottomSheetFragment();
}
public void addCallback(BottomSheetBehavior.BottomSheetCallback callback) {
bottomSheetBehavior.addBottomSheetCallback(callback);
}
public void setStateInPeek(boolean isVisible) {
if (isVisible) {
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
} else {
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
}
}
public void setVisibility(boolean visibility) {
if (visibility) {
bottomSheetView.setVisibility(View.VISIBLE);
} else {
bottomSheetView.setVisibility(View.GONE);
}
}
public void replaceFragment(int playerBottomSheet) {
fragmentManager
.beginTransaction()
.replace(
playerBottomSheet,
playerBottomSheetFragment,
"PlayerBottomSheet")
.commit();
}
public void checkAfterStateChanged(MainViewModel mainViewModel) {
final Handler handler = new Handler();
final Runnable runnable = () -> setStateInPeek(mainViewModel.isQueueLoaded());
handler.postDelayed(runnable, 100);
}
public void collapseDelayed() {
final Handler handler = new Handler();
final Runnable runnable = () -> bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
handler.postDelayed(runnable, 100);
}
public void setDraggable(Boolean isDraggable) {
bottomSheetBehavior.setDraggable((isDraggable));
}
public int getState() {
return bottomSheetBehavior.getState();
}
public void animate(float slideOffset) {
if (playerBottomSheetFragment != null) {
float condensedSlideOffset = Math.max(0.0f, Math.min(0.2f, slideOffset - 0.2f)) / 0.2f;
playerBottomSheetFragment.getPlayerHeader().setAlpha(1 - condensedSlideOffset);
playerBottomSheetFragment.getPlayerHeader().setVisibility(condensedSlideOffset > 0.99 ? View.GONE : View.VISIBLE);
}
}
public void setPeekHeight(int peekHeight, float displayDensity) {
int newPeekPx = (int) (peekHeight * displayDensity);
bottomSheetBehavior.setPeekHeight(newPeekPx);
}
}

View File

@@ -55,7 +55,7 @@ public class GithubTempoUpdateDialog extends DialogFragment {
}); });
alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener(v -> { alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener(v -> {
Preferences.setTempoUpdateReminder(); Preferences.setTempusUpdateReminder();
Objects.requireNonNull(getDialog()).dismiss(); Objects.requireNonNull(getDialog()).dismiss();
}); });

View File

@@ -0,0 +1,57 @@
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,28 +19,30 @@ 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);
return new MaterialAlertDialogBuilder(getActivity()) bind.playlistDialogChooserVisibilitySwitch.setOnCheckedChangeListener(
(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);
.setNeutralButton(R.string.playlist_chooser_dialog_neutral_button, (dialog, id) -> { }) return builder.create();
.setNegativeButton(R.string.playlist_chooser_dialog_negative_button, (dialog, id) -> dialog.cancel())
.create();
} }
@Override @Override
@@ -55,25 +57,26 @@ 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 setButtonAction() { private void launchPlaylistEditor() {
androidx.appcompat.app.AlertDialog alertDialog = (androidx.appcompat.app.AlertDialog) Objects.requireNonNull(getDialog()); Bundle bundle = new Bundle();
alertDialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> { bundle.putParcelableArrayList(
Bundle bundle = new Bundle(); Constants.TRACKS_OBJECT,
bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, playlistChooserViewModel.getSongsToAdd()); playlistChooserViewModel.getSongsToAdd()
);
PlaylistEditorDialog dialog = new PlaylistEditorDialog(null); PlaylistEditorDialog editorDialog = new PlaylistEditorDialog(null);
dialog.setArguments(bundle); editorDialog.setArguments(bundle);
dialog.show(requireActivity().getSupportFragmentManager(), null); editorDialog.show(
requireActivity().getSupportFragmentManager(),
null);
Objects.requireNonNull(getDialog()).dismiss(); dismiss();
});
} }
private void initPlaylistView() { private void initPlaylistView() {

View File

@@ -3,11 +3,13 @@ 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;
@@ -21,7 +23,6 @@ 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;
@@ -36,25 +37,26 @@ 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); radioEditorViewModel = new ViewModelProvider(requireActivity()).get(RadioEditorViewModel.class);
setupObservers();
return new MaterialAlertDialogBuilder(requireContext()) return new MaterialAlertDialogBuilder(requireContext())
.setView(bind.getRoot()) .setView(bind.getRoot())
.setTitle(R.string.radio_editor_dialog_title) .setTitle(R.string.radio_editor_dialog_title)
.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, radioHomepageURL.isEmpty() ? null : radioHomepageURL); radioEditorViewModel.createRadio(radioName, radioStreamURL,
radioHomepageURL.isEmpty() ? null : radioHomepageURL);
} else { } else {
radioEditorViewModel.updateRadio(radioName, radioStreamURL, radioHomepageURL.isEmpty() ? null : radioHomepageURL); radioEditorViewModel.updateRadio(radioName, radioStreamURL,
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();
@@ -62,6 +64,24 @@ 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();
@@ -77,7 +97,6 @@ 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());
@@ -90,22 +109,21 @@ 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() {
radioCallback.onDismiss(); if (radioCallback != null) {
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,11 +32,21 @@ 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);
@@ -74,6 +84,7 @@ 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);
@@ -106,6 +117,7 @@ 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));
@@ -137,6 +149,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)); loginViewModel.addServer(new Server(serverID, this.serverName, this.username, this.password, this.server, this.localAddress, System.currentTimeMillis(), this.lowSecurity, this.clientCertAlias));
} }
} }

View File

@@ -1,6 +1,6 @@
package com.cappielloantonio.tempo.ui.dialog; package com.cappielloantonio.tempo.ui.dialog;
import android.app.AlertDialog; import androidx.appcompat.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,13 +61,47 @@ public class TrackInfoDialog extends DialogFragment {
private void setTrackInfo() { private void setTrackInfo() {
genreLink = null; genreLink = null;
yearLink = null; yearLink = null;
bind.trakTitleInfoTextView.setText(mediaMetadata.title);
bind.trakArtistInfoTextView.setText( String type = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type") : null;
mediaMetadata.artist != null boolean isRadio = Objects.equals(type, Constants.MEDIA_TYPE_RADIO);
? mediaMetadata.artist
: mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) if (isRadio) {
? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder)) // 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.trakArtistInfoTextView.setText(
mediaMetadata.artist != null
? mediaMetadata.artist
: "");
}
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"));
@@ -90,6 +124,27 @@ public class TrackInfoDialog extends DialogFragment {
String artistValue = mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder)); String artistValue = mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder));
String genreValue = mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder)); 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,6 +2,7 @@ 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;
@@ -34,6 +35,7 @@ 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.util.Constants; import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.TileSizeManager;
import com.cappielloantonio.tempo.viewmodel.AlbumCatalogueViewModel; import com.cappielloantonio.tempo.viewmodel.AlbumCatalogueViewModel;
import java.util.ArrayList; import java.util.ArrayList;
@@ -47,7 +49,8 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
private FragmentAlbumCatalogueBinding bind; private FragmentAlbumCatalogueBinding bind;
private MainActivity activity; private MainActivity activity;
private AlbumCatalogueViewModel albumCatalogueViewModel; private AlbumCatalogueViewModel albumCatalogueViewModel;
private int spanCount = 2;
private int tileSpacing = 20;
private AlbumCatalogueAdapter albumAdapter; private AlbumCatalogueAdapter albumAdapter;
private String currentSortOrder; private String currentSortOrder;
private List<com.cappielloantonio.tempo.subsonic.models.AlbumID3> originalAlbums; private List<com.cappielloantonio.tempo.subsonic.models.AlbumID3> originalAlbums;
@@ -90,6 +93,10 @@ 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();
TileSizeManager.getInstance().calculateTileSize( requireContext() );
spanCount = TileSizeManager.getInstance().getTileSpanCount( requireContext() );
tileSpacing = TileSizeManager.getInstance().getTileSpacing( requireContext() );
initAppBar(); initAppBar();
initAlbumCatalogueView(); initAlbumCatalogueView();
initProgressLoader(); initProgressLoader();
@@ -133,8 +140,8 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
private void initAlbumCatalogueView() { private void initAlbumCatalogueView() {
bind.albumCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2)); bind.albumCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.albumCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(2, 20, false)); bind.albumCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, tileSpacing, 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.os.Parcelable; import android.util.Log;
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,6 +12,7 @@ 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;
@@ -60,12 +61,14 @@ 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);
@@ -81,7 +84,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(); init(view);
initAppBar(); initAppBar();
initAlbumInfoTextButton(); initAlbumInfoTextButton();
initAlbumNotes(); initAlbumNotes();
@@ -119,12 +122,13 @@ 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, (Parcelable) album); bundle.putParcelable(Constants.ALBUM_OBJECT, 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);
@@ -159,8 +163,21 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
return false; return false;
} }
private void init() { private void init(View view) {
albumPageViewModel.setAlbum(getViewLifecycleOwner(), requireArguments().getParcelable(Constants.ALBUM_OBJECT)); AlbumID3 albumArg = requireArguments().getParcelable(Constants.ALBUM_OBJECT);
assert albumArg != null;
albumPageViewModel.setAlbum(getViewLifecycleOwner(), albumArg);
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
favoriteToggle.setChecked(albumArg.getStarred() != null);
favoriteToggle.setOnClickListener(v -> {
albumPageViewModel.setFavorite();
});
albumPageViewModel.getAlbum().observe(getViewLifecycleOwner(), album -> {
if (album != null) {
favoriteToggle.setChecked(album.getStarred() != null);
}
});
} }
private void initAppBar() { private void initAppBar() {
@@ -244,8 +261,10 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind.albumOtherInfoButton.setOnClickListener(v -> { bind.albumOtherInfoButton.setOnClickListener(v -> {
if (bind.albumDetailView.getVisibility() == View.GONE) { if (bind.albumDetailView.getVisibility() == View.GONE) {
bind.albumDetailView.setVisibility(View.VISIBLE); bind.albumDetailView.setVisibility(View.VISIBLE);
bind.albumNameLabel.setMaxLines(Integer.MAX_VALUE);
} else if (bind.albumDetailView.getVisibility() == View.VISIBLE) { } else if (bind.albumDetailView.getVisibility() == View.VISIBLE) {
bind.albumDetailView.setVisibility(View.GONE); bind.albumDetailView.setVisibility(View.GONE);
bind.albumNameLabel.setMaxLines(2);
} }
}); });

View File

@@ -2,6 +2,7 @@ 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;
@@ -34,6 +35,8 @@ 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.ArtistCatalogueAdapter; import com.cappielloantonio.tempo.ui.adapter.ArtistCatalogueAdapter;
import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.TileSizeManager;
import com.cappielloantonio.tempo.viewmodel.ArtistCatalogueViewModel; import com.cappielloantonio.tempo.viewmodel.ArtistCatalogueViewModel;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3; import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
@@ -50,6 +53,9 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
private ArtistCatalogueAdapter artistAdapter; private ArtistCatalogueAdapter artistAdapter;
private int spanCount = 2;
private int tileSpacing = 20;
@Override @Override
public void onCreate(@Nullable Bundle savedInstanceState) { public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@@ -65,6 +71,10 @@ 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();
TileSizeManager.getInstance().calculateTileSize( requireContext() );
spanCount = TileSizeManager.getInstance().getTileSpanCount( requireContext() );
tileSpacing = TileSizeManager.getInstance().getTileSpacing( requireContext() );
initAppBar(); initAppBar();
initArtistCatalogueView(); initArtistCatalogueView();
@@ -107,14 +117,17 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
private void initArtistCatalogueView() { private void initArtistCatalogueView() {
bind.artistCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2)); bind.artistCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.artistCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(2, 20, false)); bind.artistCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, tileSpacing, false));
bind.artistCatalogueRecyclerView.setHasFixedSize(true); bind.artistCatalogueRecyclerView.setHasFixedSize(true);
artistAdapter = new ArtistCatalogueAdapter(this); artistAdapter = new ArtistCatalogueAdapter(this);
artistAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY); artistAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY);
bind.artistCatalogueRecyclerView.setAdapter(artistAdapter); bind.artistCatalogueRecyclerView.setAdapter(artistAdapter);
artistCatalogueViewModel.getArtistList().observe(getViewLifecycleOwner(), artistList -> artistAdapter.setItems(artistList)); artistCatalogueViewModel.getArtistList().observe(getViewLifecycleOwner(), artistList -> {
artistAdapter.setItems(artistList);
artistAdapter.sort(Preferences.getArtistSortOrder());
});
bind.artistCatalogueRecyclerView.setOnTouchListener((v, event) -> { bind.artistCatalogueRecyclerView.setOnTouchListener((v, event) -> {
hideKeyboard(v); hideKeyboard(v);
@@ -192,6 +205,9 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
} else if (menuItem.getItemId() == R.id.menu_artist_sort_random) { } else if (menuItem.getItemId() == R.id.menu_artist_sort_random) {
artistAdapter.sort(Constants.ARTIST_ORDER_BY_RANDOM); artistAdapter.sort(Constants.ARTIST_ORDER_BY_RANDOM);
return true; return true;
} else if (menuItem.getItemId() == R.id.menu_artist_sort_album_count) {
artistAdapter.sort(Constants.ARTIST_ORDER_BY_ALBUM_COUNT);
return true;
} }
return false; return false;

View File

@@ -2,15 +2,22 @@ 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;
@@ -28,18 +35,22 @@ 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.util.TileSizeManager;
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 {
@@ -54,6 +65,9 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture; private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private int spanCount = 2;
private int tileSpacing = 20;
@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();
@@ -63,7 +77,11 @@ 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);
init(); TileSizeManager.getInstance().calculateTileSize( requireContext() );
spanCount = TileSizeManager.getInstance().getTileSpanCount( requireContext() );
tileSpacing = TileSizeManager.getInstance().getTileSpacing( requireContext() );
init(view);
initAppBar(); initAppBar();
initArtistInfo(); initArtistInfo();
initPlayButtons(); initPlayButtons();
@@ -100,7 +118,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
bind = null; bind = null;
} }
private void init() { private void init(View view) {
artistPageViewModel.setArtist(requireArguments().getParcelable(Constants.ARTIST_OBJECT)); artistPageViewModel.setArtist(requireArguments().getParcelable(Constants.ARTIST_OBJECT));
bind.mostStreamedSongTextViewClickable.setOnClickListener(v -> { bind.mostStreamedSongTextViewClickable.setOnClickListener(v -> {
@@ -109,6 +127,14 @@ 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() {
@@ -126,53 +152,118 @@ 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 {
String normalizedBio = MusicUtil.forceReadableString(artistInfo.getBiography()); if (getContext() != null && bind != null) {
ArtistID3 currentArtist = artistPageViewModel.getArtist();
String primaryId = currentArtist.getCoverArtId() != null && !currentArtist.getCoverArtId().trim().isEmpty()
? currentArtist.getCoverArtId()
: currentArtist.getId();
final String fallbackId = (Objects.requireNonNull(primaryId).equals(currentArtist.getCoverArtId()) &&
currentArtist.getId() != null &&
!currentArtist.getId().equals(primaryId))
? currentArtist.getId()
: null;
CustomGlideRequest.Builder
.from(requireContext(), primaryId, CustomGlideRequest.ResourceType.Artist)
.build()
.listener(new com.bumptech.glide.request.RequestListener<Drawable>() {
@Override
public boolean onLoadFailed(@Nullable com.bumptech.glide.load.engine.GlideException e,
Object model,
@NonNull com.bumptech.glide.request.target.Target<Drawable> target,
boolean isFirstResource) {
if (e != null) {
e.getMessage();
if (e.getMessage().contains("400") && fallbackId != null) {
if (bind != null) Log.d("ArtistCover", "Primary ID failed (400), trying fallback: " + fallbackId);
bind.artistPageBioSector.setVisibility(!normalizedBio.trim().isEmpty() ? View.VISIBLE : View.GONE);
if (bind != null)
bind.bioMoreTextViewClickable.setVisibility(artistInfo.getLastFmUrl() != null ? View.VISIBLE : View.GONE);
if (getContext() != null && bind != null) CustomGlideRequest.Builder CustomGlideRequest.Builder
.from(requireContext(), artistPageViewModel.getArtist().getId(), CustomGlideRequest.ResourceType.Artist) .from(requireContext(), fallbackId, CustomGlideRequest.ResourceType.Artist)
.build() .build()
.into(bind.artistBackdropImageView); .into(bind.artistBackdropImageView);
return true;
}
}
return false;
}
if (bind != null) bind.bioTextView.setText(normalizedBio); @Override
public boolean onResourceReady(@NonNull Drawable resource,
@NonNull Object model,
com.bumptech.glide.request.target.Target<Drawable> target,
@NonNull com.bumptech.glide.load.DataSource dataSource,
boolean isFirstResource) {
return false;
}
})
.into(bind.artistBackdropImageView);
}
if (bind != null) bind.bioMoreTextViewClickable.setOnClickListener(v -> { if (bind != null) {
Intent intent = new Intent(Intent.ACTION_VIEW); String normalizedBio = MusicUtil.forceReadableString(artistInfo.getBiography()).trim();
intent.setData(Uri.parse(artistInfo.getLastFmUrl())); String lastFmUrl = artistInfo.getLastFmUrl();
startActivity(intent);
});
if (bind != null) bind.artistPageBioSector.setVisibility(View.VISIBLE); 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.setData(Uri.parse(artistInfo.getLastFmUrl()));
startActivity(intent);
});
bind.bioMoreTextViewClickable.setVisibility(View.VISIBLE);
}
if (!normalizedBio.isEmpty() || lastFmUrl != null) {
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 -> { bind.artistPageShuffleButton.setOnClickListener(v -> artistPageViewModel.getArtistShuffleList().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
artistPageViewModel.getArtistShuffleList().observe(getViewLifecycleOwner(), songs -> { @Override
if (!songs.isEmpty()) { public void onChanged(List<Child> songs) {
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
activity.setBottomSheetInPeek(true);
} else {
Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_tracks), Toast.LENGTH_SHORT).show();
}
});
});
bind.artistPageRadioButton.setOnClickListener(v -> {
artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), songs -> {
if (songs != null && !songs.isEmpty()) { if (songs != null && !songs.isEmpty()) {
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0); MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
activity.setBottomSheetInPeek(true); activity.setBottomSheetInPeek(true);
} else { artistPageViewModel.getArtistShuffleList().removeObserver(this);
Toast.makeText(requireContext(), getString(R.string.artist_error_retrieving_radio), Toast.LENGTH_SHORT).show();
} }
}); }
}); }));
bind.artistPageRadioButton.setOnClickListener(v -> artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> songs) {
if (songs != null && !songs.isEmpty()) {
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
activity.setBottomSheetInPeek(true);
artistPageViewModel.getArtistInstantMix().removeObserver(this);
}
}
}));
} }
private void initTopSongsView() { private void initTopSongsView() {
@@ -188,8 +279,6 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
} else { } else {
if (bind != null) if (bind != null)
bind.artistPageTopSongsSector.setVisibility(!songs.isEmpty() ? View.VISIBLE : View.GONE); bind.artistPageTopSongsSector.setVisibility(!songs.isEmpty() ? View.VISIBLE : View.GONE);
if (bind != null)
bind.artistPageShuffleButton.setEnabled(!songs.isEmpty());
songHorizontalAdapter.setItems(songs); songHorizontalAdapter.setItems(songs);
reapplyPlayback(); reapplyPlayback();
} }
@@ -197,8 +286,8 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
} }
private void initAlbumsView() { private void initAlbumsView() {
bind.albumsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2)); bind.albumsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.albumsRecyclerView.addItemDecoration(new GridItemDecoration(2, 20, false)); bind.albumsRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, tileSpacing, false));
bind.albumsRecyclerView.setHasFixedSize(true); bind.albumsRecyclerView.setHasFixedSize(true);
albumCatalogueAdapter = new AlbumCatalogueAdapter(this, false); albumCatalogueAdapter = new AlbumCatalogueAdapter(this, false);
@@ -216,8 +305,8 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
} }
private void initSimilarArtistsView() { private void initSimilarArtistsView() {
bind.similarArtistsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2)); bind.similarArtistsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.similarArtistsRecyclerView.addItemDecoration(new GridItemDecoration(2, 20, false)); bind.similarArtistsRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, tileSpacing, false));
bind.similarArtistsRecyclerView.setHasFixedSize(true); bind.similarArtistsRecyclerView.setHasFixedSize(true);
artistCatalogueAdapter = new ArtistCatalogueAdapter(this); artistCatalogueAdapter = new ArtistCatalogueAdapter(this);

View File

@@ -27,7 +27,13 @@ import com.cappielloantonio.tempo.interfaces.DialogClickCallback;
import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.model.Download;
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.repository.DirectoryRepository;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Directory;
import android.widget.Toast;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.MusicDirectoryAdapter; import com.cappielloantonio.tempo.ui.adapter.MusicDirectoryAdapter;
import com.cappielloantonio.tempo.ui.dialog.DownloadDirectoryDialog; import com.cappielloantonio.tempo.ui.dialog.DownloadDirectoryDialog;
@@ -53,6 +59,7 @@ public class DirectoryFragment extends Fragment implements ClickCallback {
private MusicDirectoryAdapter musicDirectoryAdapter; private MusicDirectoryAdapter musicDirectoryAdapter;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture; private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private DirectoryRepository directoryRepository;
private MenuItem menuItem; private MenuItem menuItem;
@@ -77,6 +84,7 @@ public class DirectoryFragment extends Fragment implements ClickCallback {
bind = FragmentDirectoryBinding.inflate(inflater, container, false); bind = FragmentDirectoryBinding.inflate(inflater, container, false);
View view = bind.getRoot(); View view = bind.getRoot();
directoryViewModel = new ViewModelProvider(requireActivity()).get(DirectoryViewModel.class); directoryViewModel = new ViewModelProvider(requireActivity()).get(DirectoryViewModel.class);
directoryRepository = new DirectoryRepository();
initAppBar(); initAppBar();
initDirectoryListView(); initDirectoryListView();
@@ -197,4 +205,57 @@ public class DirectoryFragment extends Fragment implements ClickCallback {
public void onMusicDirectoryClick(Bundle bundle) { public void onMusicDirectoryClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.directoryFragment, bundle); Navigation.findNavController(requireView()).navigate(R.id.directoryFragment, bundle);
} }
@Override
public void onMusicDirectoryPlay(Bundle bundle) {
String directoryId = bundle.getString(Constants.MUSIC_DIRECTORY_ID);
if (directoryId != null) {
Toast.makeText(requireContext(), getString(R.string.folder_play_collecting), Toast.LENGTH_SHORT).show();
collectAndPlayDirectorySongs(directoryId);
}
}
private void collectAndPlayDirectorySongs(String directoryId) {
List<Child> allSongs = new ArrayList<>();
AtomicInteger pendingRequests = new AtomicInteger(0);
collectSongsFromDirectory(directoryId, allSongs, pendingRequests, () -> {
if (!allSongs.isEmpty()) {
activity.runOnUiThread(() -> {
MediaManager.startQueue(mediaBrowserListenableFuture, allSongs, 0);
activity.setBottomSheetInPeek(true);
Toast.makeText(requireContext(), getString(R.string.folder_play_playing, allSongs.size()), Toast.LENGTH_SHORT).show();
});
} else {
activity.runOnUiThread(() -> {
Toast.makeText(requireContext(), getString(R.string.folder_play_no_songs), Toast.LENGTH_SHORT).show();
});
}
});
}
private void collectSongsFromDirectory(String directoryId, List<Child> allSongs, AtomicInteger pendingRequests, Runnable onComplete) {
pendingRequests.incrementAndGet();
directoryRepository.getMusicDirectory(directoryId).observe(getViewLifecycleOwner(), directory -> {
if (directory != null && directory.getChildren() != null) {
for (Child child : directory.getChildren()) {
if (child.isDir()) {
// It's a subdirectory, recurse into it
collectSongsFromDirectory(child.getId(), allSongs, pendingRequests, onComplete);
} else if (!child.isVideo()) {
// It's a song, add it to the list
synchronized (allSongs) {
allSongs.add(child);
}
}
}
}
// Decrement pending requests and check if we're done
if (pendingRequests.decrementAndGet() == 0) {
onComplete.run();
}
});
}
} }

View File

@@ -83,7 +83,7 @@ public class DownloadFragment extends Fragment implements ClickCallback {
super.onStart(); super.onStart();
initializeMediaBrowser(); initializeMediaBrowser();
activity.setBottomNavigationBarVisibility(true); activity.toggleBottomNavigationBarVisibilityOnOrientationChange();
activity.setBottomSheetVisibility(true); activity.setBottomSheetVisibility(true);
} }
@@ -117,14 +117,12 @@ public class DownloadFragment extends Fragment implements ClickCallback {
if (songs.isEmpty()) { if (songs.isEmpty()) {
if (bind != null) { if (bind != null) {
bind.emptyDownloadLayout.setVisibility(View.VISIBLE); bind.emptyDownloadLayout.setVisibility(View.VISIBLE);
bind.fragmentDownloadNestedScrollView.setVisibility(View.GONE);
bind.downloadDownloadedSector.setVisibility(View.GONE); bind.downloadDownloadedSector.setVisibility(View.GONE);
bind.downloadedGroupByImageView.setVisibility(View.GONE); bind.downloadedGroupByImageView.setVisibility(View.GONE);
} }
} else { } else {
if (bind != null) { if (bind != null) {
bind.emptyDownloadLayout.setVisibility(View.GONE); bind.emptyDownloadLayout.setVisibility(View.GONE);
bind.fragmentDownloadNestedScrollView.setVisibility(View.VISIBLE);
bind.downloadDownloadedSector.setVisibility(View.VISIBLE); bind.downloadDownloadedSector.setVisibility(View.VISIBLE);
bind.downloadedGroupByImageView.setVisibility(View.VISIBLE); bind.downloadedGroupByImageView.setVisibility(View.VISIBLE);

View File

@@ -3,7 +3,9 @@ package com.cappielloantonio.tempo.ui.fragment
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.BroadcastReceiver
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.view.Gravity import android.view.Gravity
@@ -12,26 +14,47 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment 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
@OptIn(UnstableApi::class)
override fun onAttach(context: Context) {
super.onAttach(context)
activity = requireActivity() as MainActivity
}
private val equalizerUpdatedReceiver = object : BroadcastReceiver() {
@OptIn(UnstableApi::class)
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == BaseMediaService.ACTION_EQUALIZER_UPDATED) {
initUI()
restoreEqualizerPreferences()
}
}
}
private val connection = object : ServiceConnection { 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 MediaService.LocalBinder val binder = service as BaseMediaService.LocalBinder
equalizerManager = binder.getEqualizerManager() equalizerManager = binder.getEqualizerManager()
initUI() initUI()
restoreEqualizerPreferences() restoreEqualizerPreferences()
@@ -46,15 +69,34 @@ 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 = MediaService.ACTION_BIND_EQUALIZER intent.action = BaseMediaService.ACTION_BIND_EQUALIZER
requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE) requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE)
} }
if (!receiverRegistered) {
ContextCompat.registerReceiver(
requireContext(),
equalizerUpdatedReceiver,
IntentFilter(BaseMediaService.ACTION_EQUALIZER_UPDATED),
ContextCompat.RECEIVER_NOT_EXPORTED
)
receiverRegistered = true
}
val showBottomBar = !Preferences.getHideBottomNavbarOnPortrait()
activity.setBottomNavigationBarVisibility(showBottomBar)
} }
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
requireActivity().unbindService(connection) requireActivity().unbindService(connection)
equalizerManager = null equalizerManager = null
if (receiverRegistered) {
try {
requireContext().unregisterReceiver(equalizerUpdatedReceiver)
} catch (_: Exception) {
// ignore if not registered
}
receiverRegistered = false
}
} }
override fun onCreateView( override fun onCreateView(
@@ -234,4 +276,4 @@ class EqualizerFragment : Fragment() {
} }
private fun Int.dpToPx(context: Context): Int = private fun Int.dpToPx(context: Context): Int =
(this * context.resources.displayMetrics.density).toInt() (this * context.resources.displayMetrics.density).toInt()

View File

@@ -2,6 +2,7 @@ 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;
@@ -32,6 +33,8 @@ 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.util.TileSizeManager;
import com.cappielloantonio.tempo.viewmodel.GenreCatalogueViewModel; import com.cappielloantonio.tempo.viewmodel.GenreCatalogueViewModel;
@OptIn(markerClass = UnstableApi.class) @OptIn(markerClass = UnstableApi.class)
@@ -42,6 +45,9 @@ public class GenreCatalogueFragment extends Fragment implements ClickCallback {
private GenreCatalogueAdapter genreCatalogueAdapter; private GenreCatalogueAdapter genreCatalogueAdapter;
private int spanCount = 2;
private int tileSpacing = 20;
@Override @Override
public void onCreate(@Nullable Bundle savedInstanceState) { public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@@ -56,6 +62,10 @@ 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);
TileSizeManager.getInstance().calculateGenreSize( requireContext() );
spanCount = TileSizeManager.getInstance().getGenreSpanCount( requireContext() );
tileSpacing = TileSizeManager.getInstance().getGenreSpacing( requireContext() );
init(); init();
initAppBar(); initAppBar();
initGenreCatalogueView(); initGenreCatalogueView();
@@ -97,8 +107,8 @@ public class GenreCatalogueFragment extends Fragment implements ClickCallback {
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
private void initGenreCatalogueView() { private void initGenreCatalogueView() {
bind.genreCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), 2)); bind.genreCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.genreCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(2, 16, false)); bind.genreCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, tileSpacing, 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.setBottomNavigationBarVisibility(true); activity.toggleBottomNavigationBarVisibilityOnOrientationChange();
activity.setBottomSheetVisibility(true); activity.setBottomSheetVisibility(true);
} }

View File

@@ -5,6 +5,8 @@ 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;
@@ -38,10 +40,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.Child;
import com.cappielloantonio.tempo.subsonic.models.Share;
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.Share;
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;
@@ -57,17 +59,18 @@ 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;
import com.cappielloantonio.tempo.util.TileSizeManager;
import com.cappielloantonio.tempo.util.UIUtil; import com.cappielloantonio.tempo.util.UIUtil;
import com.cappielloantonio.tempo.viewmodel.HomeViewModel; import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel; 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;
@@ -228,6 +231,12 @@ 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);
@@ -279,51 +288,113 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
} }
private void initSyncStarredView() { private void initSyncStarredView() {
if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) { if (Preferences.isStarredSyncEnabled()) {
homeViewModel.getAllStarredTracks().observeForever(new Observer<List<Child>>() { homeViewModel.getAllStarredTracks().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
@Override @Override
public void onChanged(List<Child> songs) { public void onChanged(List<Child> songs) {
if (songs != null) { if (songs != null && !songs.isEmpty()) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext()); int songsToSyncCount = 0;
List<String> toSync = new ArrayList<>(); List<String> toSyncSample = new ArrayList<>();
for (Child song : songs) { if (Preferences.getDownloadDirectoryUri() == null) {
if (!manager.isDownloaded(song.getId())) {
toSync.add(song.getTitle());
}
}
if (!toSync.isEmpty()) {
bind.homeSyncStarredCard.setVisibility(View.VISIBLE);
bind.homeSyncStarredTracksToSync.setText(String.join(", ", toSync));
}
}
homeViewModel.getAllStarredTracks().removeObserver(this);
}
});
}
bind.homeSyncStarredCancel.setOnClickListener(v -> bind.homeSyncStarredCard.setVisibility(View.GONE));
bind.homeSyncStarredDownload.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
homeViewModel.getAllStarredTracks().observeForever(new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> songs) {
if (songs != null) {
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)); songsToSyncCount++;
if (toSyncSample.size() < 3) {
toSyncSample.add(song.getTitle());
}
}
}
} else {
for (Child song : songs) {
if (ExternalAudioReader.getUri(song) == null) {
songsToSyncCount++;
if (toSyncSample.size() < 3) {
toSyncSample.add(song.getTitle());
}
} }
} }
} }
homeViewModel.getAllStarredTracks().removeObserver(this); if (songsToSyncCount > 0) {
bind.homeSyncStarredCard.setVisibility(View.VISIBLE);
StringBuilder displayText = new StringBuilder();
if (!toSyncSample.isEmpty()) {
displayText.append(String.join(", ", toSyncSample));
if (songsToSyncCount > 3) {
displayText.append("...");
}
}
String countText = getResources().getQuantityString(
R.plurals.home_sync_starred_songs_count,
songsToSyncCount,
songsToSyncCount
);
if (displayText.length() > 0) {
bind.homeSyncStarredTracksToSync.setText(displayText.toString() + "\n" + countText);
} else {
bind.homeSyncStarredTracksToSync.setText(countText);
}
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
} else {
bind.homeSyncStarredCard.setVisibility(View.GONE);
}
}
}
});
}
bind.homeSyncStarredCancel.setOnClickListener(v -> {
bind.homeSyncStarredCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
});
bind.homeSyncStarredDownload.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
homeViewModel.getAllStarredTracks().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> songs) {
if (songs != null && !songs.isEmpty()) {
int downloadedCount = 0;
if (Preferences.getDownloadDirectoryUri() == null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
for (Child song : songs) {
if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song));
downloadedCount++;
}
}
} else {
for (Child song : songs) {
if (ExternalAudioReader.getUri(song) == null) {
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
downloadedCount++;
}
}
}
if (downloadedCount > 0) {
Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, downloadedCount, downloadedCount),
Toast.LENGTH_SHORT).show();
}
}
bind.homeSyncStarredCard.setVisibility(View.GONE); bind.homeSyncStarredCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
} }
}); });
} }
@@ -331,6 +402,7 @@ 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
@@ -344,6 +416,9 @@ 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 -> {
@@ -351,24 +426,36 @@ 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;
for (Child song : allSongs) { if (Preferences.getDownloadDirectoryUri() == null) {
if (!manager.isDownloaded(song.getId())) { DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
manager.download(MappingUtil.mapDownload(song), new Download(song)); for (Child song : allSongs) {
songsToDownload++; if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song));
songsToDownload++;
}
}
} else {
for (Child song : allSongs) {
if (ExternalAudioReader.getUri(song) == null) {
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
songsToDownload++;
}
} }
} }
if (songsToDownload > 0) { if (songsToDownload > 0) {
Toast.makeText(requireContext(), Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload), getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
Toast.LENGTH_SHORT).show(); Toast.LENGTH_SHORT).show();
} }
} }
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE); bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
} }
}); });
}); });
@@ -379,33 +466,73 @@ 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<>();
for (AlbumID3 album : albums) { if (Preferences.getDownloadDirectoryUri() == null) {
boolean albumNeedsSync = false; DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
// Check if any songs from this album need downloading
for (Child song : allSongs) { for (AlbumID3 album : albums) {
if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) && boolean albumNeedsSync = false;
!manager.isDownloaded(song.getId())) { for (Child song : allSongs) {
songsToDownload++; if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) &&
albumNeedsSync = true; !manager.isDownloaded(song.getId())) {
songsToDownload++;
albumNeedsSync = true;
}
}
if (albumNeedsSync) {
albumsNeedingSync.add(album.getName());
} }
} }
if (albumNeedsSync) { } else {
albumsNeedingSync.add(album.getName()); 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(
R.plurals.home_sync_starred_albums_count, StringBuilder displayText = new StringBuilder();
albumsNeedingSync.size(), List<String> sampleAlbums = new ArrayList<>();
for (int i = 0; i < Math.min(albumsNeedingSync.size(), 3); i++) {
sampleAlbums.add(albumsNeedingSync.get(i));
}
if (!sampleAlbums.isEmpty()) {
displayText.append(String.join(", ", sampleAlbums));
if (albumsNeedingSync.size() > 3) {
displayText.append("...");
}
}
String countText = getResources().getQuantityString(
R.plurals.home_sync_starred_albums_count,
albumsNeedingSync.size(),
albumsNeedingSync.size() 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);
} }
@@ -428,6 +555,9 @@ 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 -> {
@@ -435,24 +565,36 @@ 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;
for (Child song : allSongs) { if (Preferences.getDownloadDirectoryUri() == null) {
if (!manager.isDownloaded(song.getId())) { DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
manager.download(MappingUtil.mapDownload(song), new Download(song)); for (Child song : allSongs) {
songsToDownload++; if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song));
songsToDownload++;
}
}
} else {
for (Child song : allSongs) {
if (ExternalAudioReader.getUri(song) == null) {
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
songsToDownload++;
}
} }
} }
if (songsToDownload > 0) { if (songsToDownload > 0) {
Toast.makeText(requireContext(), Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload), getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
Toast.LENGTH_SHORT).show(); Toast.LENGTH_SHORT).show();
} }
} }
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE); bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
if (getActivity() != null) {
getActivity().runOnUiThread(() -> reorder());
}
} }
}); });
}); });
@@ -463,33 +605,73 @@ 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<>();
for (ArtistID3 artist : artists) { if (Preferences.getDownloadDirectoryUri() == null) {
boolean artistNeedsSync = false; DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
// Check if any songs from this artist need downloading
for (Child song : allSongs) { for (ArtistID3 artist : artists) {
if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) && boolean artistNeedsSync = false;
!manager.isDownloaded(song.getId())) { for (Child song : allSongs) {
songsToDownload++; if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) &&
artistNeedsSync = true; !manager.isDownloaded(song.getId())) {
songsToDownload++;
artistNeedsSync = true;
}
}
if (artistNeedsSync) {
artistsNeedingSync.add(artist.getName());
} }
} }
if (artistNeedsSync) { } else {
artistsNeedingSync.add(artist.getName()); 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(
R.plurals.home_sync_starred_artists_count, StringBuilder displayText = new StringBuilder();
artistsNeedingSync.size(), List<String> sampleArtists = new ArrayList<>();
for (int i = 0; i < Math.min(artistsNeedingSync.size(), 3); i++) {
sampleArtists.add(artistsNeedingSync.get(i));
}
if (!sampleArtists.isEmpty()) {
displayText.append(String.join(", ", sampleArtists));
if (artistsNeedingSync.size() > 3) {
displayText.append("...");
}
}
String countText = getResources().getQuantityString(
R.plurals.home_sync_starred_artists_count,
artistsNeedingSync.size(),
artistsNeedingSync.size() 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);
} }
@@ -497,15 +679,16 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
} }
}); });
} }
private void initDiscoverSongSlideView() { private void initDiscoverSongSlideView() {
if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_DISCOVERY)) return; if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_DISCOVERY)) return;
bind.discoverSongViewPager.setOrientation(ViewPager2.ORIENTATION_HORIZONTAL); bind.discoverSongRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false));
bind.discoverSongRecyclerView.setHasFixedSize(true);
discoverSongAdapter = new DiscoverSongAdapter(this); discoverSongAdapter = new DiscoverSongAdapter(this);
bind.discoverSongViewPager.setAdapter(discoverSongAdapter); bind.discoverSongRecyclerView.setAdapter(discoverSongAdapter);
bind.discoverSongViewPager.setOffscreenPageLimit(1);
homeViewModel.getDiscoverSongSample(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), songs -> { homeViewModel.getDiscoverSongSample(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), songs -> {
MusicUtil.ratingFilter(songs); MusicUtil.ratingFilter(songs);
@@ -518,8 +701,6 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
discoverSongAdapter.setItems(songs); discoverSongAdapter.setItems(songs);
} }
}); });
setSlideViewOffset(bind.discoverSongViewPager, 20, 16);
} }
private void initSimilarSongView() { private void initSimilarSongView() {
@@ -962,6 +1143,18 @@ 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;
@@ -1062,20 +1255,25 @@ 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)) {
MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelable(Constants.TRACK_OBJECT)); Child track = bundle.getParcelable(Constants.TRACK_OBJECT);
activity.setBottomSheetInPeek(true); activity.setBottomSheetInPeek(true);
if (mediaBrowserListenableFuture != null) { if (mediaBrowserListenableFuture != null) {
homeViewModel.getMediaInstantMix(getViewLifecycleOwner(), bundle.getParcelable(Constants.TRACK_OBJECT)).observe(getViewLifecycleOwner(), songs -> { final boolean[] playbackStarted = {false};
MusicUtil.ratingFilter(songs); Toast.makeText(requireContext(), R.string.bottom_sheet_generating_instant_mix, Toast.LENGTH_SHORT).show();
homeViewModel.getMediaInstantMix(getViewLifecycleOwner(), track)
.observe(getViewLifecycleOwner(), songs -> {
if (playbackStarted[0] || songs == null || songs.isEmpty()) return;
if (songs != null && !songs.isEmpty()) { new Handler(Looper.getMainLooper()).postDelayed(() -> {
MediaManager.enqueue(mediaBrowserListenableFuture, songs, true); if (playbackStarted[0]) return;
}
}); MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
playbackStarted[0] = true;
}, 300);
});
} }
} else if (bundle.containsKey(Constants.MEDIA_CHRONOLOGY)) { } else if (bundle.containsKey(Constants.MEDIA_CHRONOLOGY)) {
List<Child> media = bundle.getParcelableArrayList(Constants.TRACKS_OBJECT); List<Child> media = bundle.getParcelableArrayList(Constants.TRACKS_OBJECT);

View File

@@ -1,27 +1,40 @@
package com.cappielloantonio.tempo.ui.fragment; package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName;
import android.os.Bundle; import android.os.Bundle;
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.core.view.ViewCompat; import androidx.core.view.ViewCompat;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
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.SessionToken;
import androidx.navigation.Navigation; import androidx.navigation.Navigation;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.FragmentIndexBinding; import com.cappielloantonio.tempo.databinding.FragmentIndexBinding;
import com.cappielloantonio.tempo.interfaces.ClickCallback; import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.repository.DirectoryRepository;
import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.MusicFolder; import com.cappielloantonio.tempo.subsonic.models.MusicFolder;
import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.MusicIndexAdapter; import com.cappielloantonio.tempo.ui.adapter.MusicIndexAdapter;
import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.IndexUtil; import com.cappielloantonio.tempo.util.IndexUtil;
import com.cappielloantonio.tempo.viewmodel.IndexViewModel; import com.cappielloantonio.tempo.viewmodel.IndexViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@UnstableApi @UnstableApi
public class IndexFragment extends Fragment implements ClickCallback { public class IndexFragment extends Fragment implements ClickCallback {
@@ -32,6 +45,8 @@ public class IndexFragment extends Fragment implements ClickCallback {
private IndexViewModel indexViewModel; private IndexViewModel indexViewModel;
private MusicIndexAdapter musicIndexAdapter; private MusicIndexAdapter musicIndexAdapter;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private DirectoryRepository directoryRepository;
@Override @Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
@@ -40,6 +55,7 @@ public class IndexFragment extends Fragment implements ClickCallback {
bind = FragmentIndexBinding.inflate(inflater, container, false); bind = FragmentIndexBinding.inflate(inflater, container, false);
View view = bind.getRoot(); View view = bind.getRoot();
indexViewModel = new ViewModelProvider(requireActivity()).get(IndexViewModel.class); indexViewModel = new ViewModelProvider(requireActivity()).get(IndexViewModel.class);
directoryRepository = new DirectoryRepository();
initAppBar(); initAppBar();
initDirectoryListView(); initDirectoryListView();
@@ -48,6 +64,18 @@ public class IndexFragment extends Fragment implements ClickCallback {
return view; return view;
} }
@Override
public void onStart() {
super.onStart();
initializeMediaBrowser();
}
@Override
public void onStop() {
releaseMediaBrowser();
super.onStop();
}
@Override @Override
public void onDestroyView() { public void onDestroyView() {
super.onDestroyView(); super.onDestroyView();
@@ -107,4 +135,65 @@ public class IndexFragment extends Fragment implements ClickCallback {
public void onMusicIndexClick(Bundle bundle) { public void onMusicIndexClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.directoryFragment, bundle); Navigation.findNavController(requireView()).navigate(R.id.directoryFragment, bundle);
} }
@Override
public void onMusicIndexPlay(Bundle bundle) {
String directoryId = bundle.getString(Constants.MUSIC_DIRECTORY_ID);
if (directoryId != null) {
Toast.makeText(requireContext(), getString(R.string.folder_play_collecting), Toast.LENGTH_SHORT).show();
collectAndPlayDirectorySongs(directoryId);
}
}
private void initializeMediaBrowser() {
mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync();
}
private void releaseMediaBrowser() {
MediaBrowser.releaseFuture(mediaBrowserListenableFuture);
}
private void collectAndPlayDirectorySongs(String directoryId) {
List<Child> allSongs = new ArrayList<>();
AtomicInteger pendingRequests = new AtomicInteger(0);
collectSongsFromDirectory(directoryId, allSongs, pendingRequests, () -> {
if (!allSongs.isEmpty()) {
activity.runOnUiThread(() -> {
MediaManager.startQueue(mediaBrowserListenableFuture, allSongs, 0);
activity.setBottomSheetInPeek(true);
Toast.makeText(requireContext(), getString(R.string.folder_play_playing, allSongs.size()), Toast.LENGTH_SHORT).show();
});
} else {
activity.runOnUiThread(() -> {
Toast.makeText(requireContext(), getString(R.string.folder_play_no_songs), Toast.LENGTH_SHORT).show();
});
}
});
}
private void collectSongsFromDirectory(String directoryId, List<Child> allSongs, AtomicInteger pendingRequests, Runnable onComplete) {
pendingRequests.incrementAndGet();
directoryRepository.getMusicDirectory(directoryId).observe(getViewLifecycleOwner(), directory -> {
if (directory != null && directory.getChildren() != null) {
for (Child child : directory.getChildren()) {
if (child.isDir()) {
// It's a subdirectory, recurse into it
collectSongsFromDirectory(child.getId(), allSongs, pendingRequests, onComplete);
} else if (!child.isVideo()) {
// It's a song, add it to the list
synchronized (allSongs) {
allSongs.add(child);
}
}
}
}
// Decrement pending requests and check if we're done
if (pendingRequests.decrementAndGet() == 0) {
onComplete.run();
}
});
}
} }

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