46 Commits

Author SHA1 Message Date
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
208 changed files with 8072 additions and 1888 deletions

View File

@@ -1,6 +1,78 @@
# Changelog
## Pending release
## 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)

View File

@@ -1,5 +1,5 @@
<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>
---
@@ -84,13 +84,13 @@ Please note the two variants in the release assets include release/debug and 32/
</p>
<p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1_light.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2_light.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3_light.png" width=200>
<img src="mockup/1_light_tempus.png" width=200>
<img src="mockup/2_light_tempus.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/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/8_light.png" width=200>
<!-- <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/8_light.png" width=200> -->
</p>
<br>
@@ -100,13 +100,13 @@ Please note the two variants in the release assets include release/debug and 32/
</p>
<p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1_dark.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2_dark.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3_dark.png" width=200>
<img src="mockup/1_dark_tempus.png" width=200>
<img src="mockup/2_dark_tempus.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/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/8_dark.png" width=200>
<!-- <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/8_dark.png" width=200> -->
</p>
@@ -136,4 +136,4 @@ Tempus is released under the [GNU General Public License v3.0](LICENSE). Feel fr
## Credits
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.

View File

@@ -158,7 +158,8 @@ If your server supports it - add a internet radio station feed
## Android Auto
### Enabling on your head unit
**Enabling on your head unit**
To allow the Tempus app on your car's head unit, "Unknown sources" needs to be enabled in the Android Auto "Developer settings". This is because Tempus isn't installed through Play Store. Note that the Android Auto developer settings are different from the global Android "Developer options".
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">
@@ -177,6 +178,61 @@ To allow the Tempus app on your car's head unit, "Unknown sources" needs to be e
<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
**IN PROGRESS**

View File

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

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

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"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="512"
android:viewportHeight="512">
<group android:scaleX="0.49"
android:scaleY="0.49"
android:translateX="130.56"
android:translateY="130.56">
android:viewportWidth="108"
android:viewportHeight="108">
<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:fillColor="#8CC152"/> <path
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:fillColor="#62A43B"/> <path
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
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:fillColor="#E6E9ED"/>
<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:fillColor="#E6E9ED"/>
<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:fillColor="#E6E9ED"/>
<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:fillColor="#E6E9ED"/>
<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="#CCD1D9"/>
<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: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>
<group
android:scaleX="0.13"
android:scaleY="0.13"
android:translateX="21.5"
android:translateY="21.5">
<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,53 +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="512">
<group android:scaleX="0.55"
android:scaleY="0.55"
android:translateX="150.56"
android:translateY="150.56">
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="0.13"
android:scaleY="0.13"
android:translateX="21.5"
android:translateY="21.5">
<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:fillColor="#8CC152"/> <path
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:fillColor="#62A43B"/> <path
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
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:fillColor="#E6E9ED"/>
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="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:fillColor="#E6E9ED"/>
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="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:fillColor="#E6E9ED"/>
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="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:fillColor="#E6E9ED"/>
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: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="#CCD1D9"/>
<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: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>
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,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"?>
<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"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</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

@@ -98,7 +98,7 @@
<provider
android:name=".provider.AlbumArtContentProvider"
android:authorities="com.cappielloantonio.tempo.provider"
android:authorities="${applicationId}.albumart.provider"
android:enabled="true"
android:exported="true"
/>

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.subsonic.Subsonic;
import com.cappielloantonio.tempo.subsonic.SubsonicPreferences;
import com.cappielloantonio.tempo.util.ClientCertManager;
import com.cappielloantonio.tempo.util.Preferences;
public class App extends Application {
@@ -31,6 +32,8 @@ public class App extends Application {
instance = new App();
context = getApplicationContext();
preferences = PreferenceManager.getDefaultSharedPreferences(context);
ClientCertManager.setupSslSocketFactory(context);
}
public static App getInstance() {
@@ -55,6 +58,48 @@ public class App extends Application {
}
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() {
if (github == null) {

View File

@@ -30,9 +30,13 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
@UnstableApi
@Database(
version = 13,
version = 14,
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})
public abstract class AppDatabase extends RoomDatabase {

View File

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

View File

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

View File

@@ -195,11 +195,20 @@ class SessionMediaItem() {
title = internetRadioStation.name
streamUrl = internetRadioStation.streamUrl
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 {
val uri: Uri = getStreamUri()
val artworkUri = AlbumArtContentProvider.contentUri(coverArtId)
val artworkUri = if (coverArtId != null) AlbumArtContentProvider.contentUri(coverArtId!!) else null
val bundle = Bundle()
bundle.putString("id", id)
@@ -229,7 +238,7 @@ class SessionMediaItem() {
bundle.putLong("starred", starred?.time ?: 0)
bundle.putString("albumId", albumId)
bundle.putString("artistId", artistId)
bundle.putString("type", Constants.MEDIA_TYPE_MUSIC)
bundle.putString("type", type)
bundle.putLong("bookmarkPosition", bookmarkPosition ?: 0)
bundle.putInt("originalWidth", originalWidth ?: 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

@@ -8,12 +8,14 @@ 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;
@@ -28,7 +30,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class AlbumArtContentProvider extends ContentProvider {
public static final String AUTHORITY = "com.cappielloantonio.tempo.provider";
public static final String AUTHORITY = BuildConfig.APPLICATION_ID + ".albumart.provider";
public static final String ALBUM_ART = "albumArt";
private ExecutorService executor;
@@ -52,7 +54,15 @@ public class AlbumArtContentProvider extends ContentProvider {
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException {
Context context = getContext();
String albumId = uri.getLastPathSegment();
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(albumId, Preferences.getImageSize()));
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()

View File

@@ -1,6 +1,5 @@
package com.cappielloantonio.tempo.repository;
import android.content.ContentResolver;
import android.net.Uri;
import android.view.View;
@@ -36,6 +35,7 @@ import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
import com.cappielloantonio.tempo.subsonic.models.MusicFolder;
import com.cappielloantonio.tempo.subsonic.models.Playlist;
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.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
@@ -69,6 +69,16 @@ public class AutomotiveRepository {
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();
// 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<>();
for (AlbumID3 album : albums) {
@@ -606,20 +616,7 @@ public class AutomotiveRepository {
List<MediaItem> mediaItems = new ArrayList<>();
for (InternetRadioStation radioStation : radioStations) {
MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setTitle(radioStation.getName())
.setIsBrowsable(false)
.setIsPlayable(true)
.setMediaType(MediaMetadata.MEDIA_TYPE_RADIO_STATION)
.build();
MediaItem mediaItem = new MediaItem.Builder()
.setMediaId(radioStation.getId())
.setMediaMetadata(mediaMetadata)
.setUri(radioStation.getStreamUrl())
.build();
mediaItems.add(mediaItem);
mediaItems.add(MappingUtil.mapInternetRadioStation(radioStation));
}
setInternetRadioStationsMetadata(radioStations);
@@ -793,7 +790,7 @@ public class AutomotiveRepository {
App.getSubsonicClientInstance(false)
.getSearchingClient()
.search3(query, 20, 20, 20)
.search3(query, 20, 0, 20, 0, 20, 0)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
@@ -956,6 +953,116 @@ public class AutomotiveRepository {
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 final SessionMediaItemDao sessionMediaItemDao;
private final String id;

View File

@@ -3,8 +3,11 @@ package com.cappielloantonio.tempo.repository;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.R;
@@ -23,8 +26,45 @@ import retrofit2.Callback;
import retrofit2.Response;
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
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) {
MutableLiveData<List<Playlist>> listLivePlaylists = new MutableLiveData<>(new ArrayList<>());
@@ -104,9 +144,16 @@ public class PlaylistRepository {
return playlistLiveData;
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId, Boolean playlistVisibilityIsPublic) {
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()) {
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{
App.getSubsonicClientInstance(false)
.getPlaylistClient()
@@ -114,17 +161,45 @@ public class PlaylistRepository {
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show();
if (response.isSuccessful()) notifyPlaylistChanged();
if (callback != null) callback.onSuccess();
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show();
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) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
@@ -132,7 +207,7 @@ public class PlaylistRepository {
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) notifyPlaylistChanged();
}
@Override
@@ -145,20 +220,45 @@ public class PlaylistRepository {
public void updatePlaylist(String playlistId, String name, ArrayList<String> songsId) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.deletePlaylist(playlistId)
.updatePlaylist(playlistId, name, true, null, null)
.enqueue(new Callback<ApiResponse>() {
@Override
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
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) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
@@ -166,7 +266,7 @@ public class PlaylistRepository {
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful()) notifyPlaylistChanged();
}
@Override
@@ -194,6 +294,49 @@ public class PlaylistRepository {
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 final PlaylistDao playlistDao;
private final Playlist playlist;

View File

@@ -1,9 +1,14 @@
package com.cappielloantonio.tempo.repository;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.NonNull;
import androidx.lifecycle.MutableLiveData;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.database.AppDatabase;
import com.cappielloantonio.tempo.database.dao.RecentSearchDao;
import com.cappielloantonio.tempo.model.RecentSearch;
@@ -11,13 +16,18 @@ import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Playlist;
import com.cappielloantonio.tempo.subsonic.models.PlaylistWithSongs;
import com.cappielloantonio.tempo.subsonic.models.SearchResult2;
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.LinkedHashSet;
import java.util.List;
import java.util.concurrent.Executors;
import retrofit2.Call;
import retrofit2.Callback;
@@ -31,7 +41,7 @@ public class SearchingRepository {
App.getSubsonicClientInstance(false)
.getSearchingClient()
.search3(query, 20, 20, 20)
.search3(query, 20, 0, 20, 0, 20, 0)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
@@ -49,12 +59,63 @@ public class SearchingRepository {
return result;
}
public MutableLiveData<SearchResult3> search3(String query) {
@UnstableApi
public MutableLiveData<SearchResult3> search3(SearchFragment sf, String query) {
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)
.getSearchingClient()
.search3(query, 20, 20, 20)
.search3(query, 20, 0, 20, 0, 20, 0)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
@@ -77,7 +138,7 @@ public class SearchingRepository {
App.getSubsonicClientInstance(false)
.getSearchingClient()
.search3(query, 5, 5, 5)
.search3(query, 5, 0, 5, 0, 5, 0)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {

View File

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

View File

@@ -24,6 +24,9 @@ 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
@@ -32,6 +35,12 @@ 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"
@@ -70,6 +79,13 @@ open class BaseMediaService : MediaLibraryService() {
}
}
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() {
@@ -120,6 +136,9 @@ open class BaseMediaService : MediaLibraryService() {
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) {
@@ -129,6 +148,16 @@ open class BaseMediaService : MediaLibraryService() {
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)
}
@@ -170,6 +199,96 @@ open class BaseMediaService : MediaLibraryService() {
}
}
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) {
@@ -182,8 +301,10 @@ open class BaseMediaService : MediaLibraryService() {
}
if (isPlaying) {
scheduleWidgetUpdates()
scheduleRadioHeaderChecks()
} else {
stopWidgetUpdates()
stopRadioHeaderChecks()
}
updateWidget(player)
}
@@ -287,6 +408,8 @@ open class BaseMediaService : MediaLibraryService() {
releaseNetworkCallback()
equalizerManager.release()
stopWidgetUpdates()
stopRadioHeaderChecks()
radioHeaderCheckExecutor.shutdown()
releasePlayers()
mediaLibrarySession.release()
super.onDestroy()
@@ -405,6 +528,148 @@ open class BaseMediaService : MediaLibraryService() {
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)
@@ -595,4 +860,5 @@ open class BaseMediaService : MediaLibraryService() {
}
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

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

View File

@@ -24,8 +24,8 @@ public class SearchingClient {
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()");
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);
@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

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

View File

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

View File

@@ -2,31 +2,28 @@ package com.cappielloantonio.tempo.ui.activity;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.Rect;
import android.content.IntentFilter;
import android.content.res.Configuration;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.core.splashscreen.SplashScreen;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment;
import androidx.navigation.ui.NavigationUI;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.BuildConfig;
@@ -34,8 +31,12 @@ import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.broadcast.receiver.ConnectivityStatusBroadcastReceiver;
import com.cappielloantonio.tempo.databinding.ActivityMainBinding;
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.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.GithubTempoUpdateDialog;
import com.cappielloantonio.tempo.ui.dialog.ServerUnreachableDialog;
@@ -48,6 +49,7 @@ import com.cappielloantonio.tempo.viewmodel.MainViewModel;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import com.google.android.material.color.DynamicColors;
import com.google.android.material.navigation.NavigationView;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.Objects;
@@ -63,15 +65,24 @@ public class MainActivity extends BaseActivity {
private FragmentManager fragmentManager;
private NavHostFragment navHostFragment;
private BottomNavigationView bottomNavigationView;
private FrameLayout bottomNavigationViewFrame;
private DrawerLayout drawerLayout;
private NavigationView navigationView;
public NavController navController;
private BottomSheetBehavior bottomSheetBehavior;
private boolean isLandscape = false;
private NavigationController navigationController;
private BottomSheetController bottomSheetController;
public BottomSheetBehavior bottomSheetBehavior;
public boolean isLandscape = false;
private AssetLinkNavigator assetLinkNavigator;
private AssetLinkUtil.AssetLink pendingAssetLink;
ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver;
private Intent pendingDownloadPlaybackIntent;
public ActivityMainBinding getBinding() {
return bind;
}
@Override
protected void onCreate(Bundle savedInstanceState) {
SplashScreen.installSplashScreen(this);
@@ -111,6 +122,7 @@ public class MainActivity extends BaseActivity {
protected void onResume() {
super.onResume();
pingServer();
toggleNavigationDrawerLockOnOrientationChange();
}
@Override
@@ -137,7 +149,6 @@ public class MainActivity extends BaseActivity {
}
public void init() {
fragmentManager = getSupportFragmentManager();
initBottomSheet();
initNavigation();
@@ -148,59 +159,78 @@ public class MainActivity extends BaseActivity {
goToLogin();
}
// Set bottom navigation height
if (isLandscape) {
ViewGroup.LayoutParams layoutParams = bottomNavigationView.getLayoutParams();
Rect windowRect = new Rect();
bottomNavigationView.getWindowVisibleDisplayFrame(windowRect);
layoutParams.width = windowRect.height();
bottomNavigationView.setLayoutParams(layoutParams);
}
toggleNavigationDrawerLockOnOrientationChange();
}
// BOTTOM SHEET/NAVIGATION
private void initBottomSheet() {
bottomSheetBehavior = BottomSheetBehavior.from(findViewById(R.id.player_bottom_sheet));
bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback);
fragmentManager.beginTransaction().replace(R.id.player_bottom_sheet, new PlayerBottomSheetFragment(), "PlayerBottomSheet").commit();
private void initNavigation() {
// We link the nav_graph.xml with our navigationController
NavHostFragment navHostFragment = (NavHostFragment) this
.getSupportFragmentManager()
.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) {
if (isVisible) {
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
} else {
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
}
bottomSheetController.setStateInPeek(isVisible);
}
public void setBottomSheetVisibility(boolean visibility) {
if (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);
bottomSheetController.setVisibility(visibility);
}
public void collapseBottomSheetDelayed() {
final Handler handler = new Handler();
final Runnable runnable = () -> bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
handler.postDelayed(runnable, 100);
bottomSheetController.collapseDelayed();
}
public void expandBottomSheet() {
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
bottomSheetController.expand();
}
public void setBottomSheetDraggableState(Boolean isDraggable) {
bottomSheetBehavior.setDraggable(isDraggable);
bottomSheetController.setDraggable(isDraggable);
}
private final BottomSheetBehavior.BottomSheetCallback bottomSheetCallback =
@@ -213,7 +243,7 @@ public class MainActivity extends BaseActivity {
switch (state) {
case BottomSheetBehavior.STATE_HIDDEN:
resetMusicSession();
resetMusicSession(); // I can't put the callback inside BottomSheetHelper because of this line
break;
case BottomSheetBehavior.STATE_COLLAPSED:
if (playerBottomSheetFragment != null)
@@ -237,12 +267,7 @@ public class MainActivity extends BaseActivity {
};
private void animateBottomSheet(float slideOffset) {
PlayerBottomSheetFragment playerBottomSheetFragment = (PlayerBottomSheetFragment) getSupportFragmentManager().findFragmentByTag("PlayerBottomSheet");
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);
}
bottomSheetController.animate(slideOffset);
}
private void animateBottomNavigation(float slideOffset, int navigationHeight) {
@@ -257,36 +282,57 @@ public class MainActivity extends BaseActivity {
bind.bottomNavigation.setTranslationY(slideY);
}
private void initNavigation() {
bottomNavigationView = findViewById(R.id.bottom_navigation);
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) {
navigationController.setNavbarVisibility(visibility);
}
public void setBottomNavigationBarVisibility(boolean visibility) {
if (visibility) {
bottomNavigationView.setVisibility(View.VISIBLE);
public void toggleBottomNavigationBarVisibilityOnOrientationChange() {
float displayDensity = getResources().getDisplayMetrics().density;
// 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 {
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() {
MediaManager.check(getMediaBrowserListenableFuture());
@@ -321,7 +367,7 @@ public class MainActivity extends BaseActivity {
}
private void goToHome() {
bottomNavigationView.setVisibility(View.VISIBLE);
setBottomNavigationBarVisibility(true);
if (Objects.requireNonNull(navController.getCurrentDestination()).getId() == R.id.landingFragment) {
navController.navigate(R.id.action_landingFragment_to_homeFragment);
@@ -368,6 +414,7 @@ public class MainActivity extends BaseActivity {
Preferences.setServer(null);
Preferences.setLocalAddress(null);
Preferences.setUser(null);
Preferences.setClientCert(null);
// TODO Enter all settings to be reset
Preferences.setOpenSubsonic(false);

View File

@@ -13,6 +13,7 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.TileSizeManager;
import java.util.Collections;
import java.util.List;
@@ -22,6 +23,8 @@ public class AlbumAdapter extends RecyclerView.Adapter<AlbumAdapter.ViewHolder>
private List<AlbumID3> albums;
private int sizePx = 400;
public AlbumAdapter(ClickCallback click) {
this.click = click;
this.albums = Collections.emptyList();
@@ -31,11 +34,20 @@ public class AlbumAdapter extends RecyclerView.Adapter<AlbumAdapter.ViewHolder>
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
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);
}
@Override
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);
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.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.TileSizeManager;
import java.util.Collections;
import java.util.List;
@@ -24,6 +25,7 @@ public class ArtistAdapter extends RecyclerView.Adapter<ArtistAdapter.ViewHolder
private final boolean mix;
private final boolean bestOf;
private int sizePx = 400;
private List<ArtistID3> artists;
public ArtistAdapter(ClickCallback click, Boolean mix, Boolean bestOf) {
@@ -37,11 +39,20 @@ public class ArtistAdapter extends RecyclerView.Adapter<ArtistAdapter.ViewHolder
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
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);
}
@Override
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);
holder.item.artistNameLabel.setText(artist.getName());

View File

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

View File

@@ -13,6 +13,7 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.subsonic.models.SimilarArtistID3;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.TileSizeManager;
import java.util.Collections;
import java.util.List;
@@ -22,6 +23,8 @@ public class ArtistSimilarAdapter extends RecyclerView.Adapter<ArtistSimilarAdap
private List<SimilarArtistID3> artists;
private int sizePx = 400;
public ArtistSimilarAdapter(ClickCallback click) {
this.click = click;
this.artists = Collections.emptyList();
@@ -31,11 +34,20 @@ public class ArtistSimilarAdapter extends RecyclerView.Adapter<ArtistSimilarAdap
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
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);
}
@Override
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);
holder.item.artistNameLabel.setText(artist.getName());

View File

@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.adapter;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
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.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.TileSizeManager;
import java.util.Collections;
import java.util.List;
@@ -23,6 +25,9 @@ public class DiscoverSongAdapter extends RecyclerView.Adapter<DiscoverSongAdapte
private List<Child> songs;
private int widthPx = 800;
private int heightPx = 400;
public DiscoverSongAdapter(ClickCallback click) {
this.click = click;
this.songs = Collections.emptyList();
@@ -32,11 +37,21 @@ public class DiscoverSongAdapter extends RecyclerView.Adapter<DiscoverSongAdapte
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
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);
}
@Override
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);
holder.item.titleDiscoverSongLabel.setText(song.getTitle());

View File

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

View File

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

View File

@@ -359,6 +359,7 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
private boolean onLongClick() {
Bundle bundle = new Bundle();
bundle.putParcelable(Constants.TRACK_OBJECT, songs.get(getBindingAdapterPosition()));
bundle.putInt(Constants.ITEM_POSITION, getBindingAdapterPosition());
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

@@ -6,7 +6,6 @@ import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
@@ -20,41 +19,30 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.viewmodel.PlaylistChooserViewModel;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
public class PlaylistChooserDialog extends DialogFragment implements ClickCallback {
private DialogPlaylistChooserBinding bind;
private PlaylistChooserViewModel playlistChooserViewModel;
private PlaylistDialogHorizontalAdapter playlistDialogHorizontalAdapter;
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
DialogPlaylistChooserBinding.inflate(getLayoutInflater());
bind = DialogPlaylistChooserBinding.inflate(getLayoutInflater());
playlistChooserViewModel = new ViewModelProvider(requireActivity()).get(PlaylistChooserViewModel.class);
String[] playlistVisibilityChoice = {
getString(R.string.playlist_chooser_dialog_visibility_public),
getString(R.string.playlist_chooser_dialog_visibility_private)
};
bind.playlistDialogChooserVisibilitySwitch.setOnCheckedChangeListener(
(buttonView,
isChecked) -> playlistChooserViewModel.setIsPlaylistPublic(isChecked)
);
bind.playlistChooserDialogCreateButton.setOnClickListener(v -> launchPlaylistEditor());
bind.playlistChooserDialogCancelButton.setOnClickListener(v -> dismiss());
return new MaterialAlertDialogBuilder(getActivity())
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext())
.setView(bind.getRoot())
.setTitle(R.string.playlist_chooser_dialog_title)
.setSingleChoiceItems(
playlistVisibilityChoice,
0,
(dialog, which) -> {
boolean isPublic = (which == 0);
playlistChooserViewModel.setIsPlaylistPublic(isPublic);
})
.setNeutralButton(R.string.playlist_chooser_dialog_neutral_button, (dialog, id) -> { })
.setNegativeButton(R.string.playlist_chooser_dialog_negative_button, (dialog, id) -> dialog.cancel())
.create();
.setTitle(R.string.playlist_chooser_dialog_title);
return builder.create();
}
@Override
@@ -69,25 +57,26 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba
initPlaylistView();
setSongInfo();
setButtonAction();
}
private void setSongInfo() {
playlistChooserViewModel.setSongsToAdd(requireArguments().getParcelableArrayList(Constants.TRACKS_OBJECT));
}
private void setButtonAction() {
androidx.appcompat.app.AlertDialog alertDialog = (androidx.appcompat.app.AlertDialog) Objects.requireNonNull(getDialog());
alertDialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> {
Bundle bundle = new Bundle();
bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, playlistChooserViewModel.getSongsToAdd());
private void launchPlaylistEditor() {
Bundle bundle = new Bundle();
bundle.putParcelableArrayList(
Constants.TRACKS_OBJECT,
playlistChooserViewModel.getSongsToAdd()
);
PlaylistEditorDialog dialog = new PlaylistEditorDialog(null);
dialog.setArguments(bundle);
dialog.show(requireActivity().getSupportFragmentManager(), null);
PlaylistEditorDialog editorDialog = new PlaylistEditorDialog(null);
editorDialog.setArguments(bundle);
editorDialog.show(
requireActivity().getSupportFragmentManager(),
null);
Objects.requireNonNull(getDialog()).dismiss();
});
dismiss();
}
private void initPlaylistView() {

View File

@@ -2,8 +2,8 @@ package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog;
import android.os.Bundle;
import android.security.KeyChain;
import android.text.TextUtils;
import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
@@ -32,11 +32,21 @@ public class ServerSignupDialog extends DialogFragment {
private String server;
private String localAddress;
private boolean lowSecurity = false;
private String clientCertAlias;
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
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);
@@ -74,6 +84,7 @@ public class ServerSignupDialog extends DialogFragment {
bind.serverTextView.setText(loginViewModel.getServerToEdit().getAddress());
bind.localAddressTextView.setText(loginViewModel.getServerToEdit().getLocalAddress());
bind.lowSecurityCheckbox.setChecked(loginViewModel.getServerToEdit().isLowSecurity());
bind.clientCertTextView.setText(loginViewModel.getServerToEdit().getClientCert());
}
} else {
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;
localAddress = bind.localAddressTextView.getText() != null && !bind.localAddressTextView.getText().toString().trim().isBlank() ? bind.localAddressTextView.getText().toString().trim() : null;
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)) {
bind.serverNameTextView.setError(getString(R.string.error_required));
@@ -137,6 +149,6 @@ public class ServerSignupDialog extends DialogFragment {
private void saveServerPreference() {
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

@@ -61,13 +61,47 @@ public class TrackInfoDialog extends DialogFragment {
private void setTrackInfo() {
genreLink = null;
yearLink = null;
bind.trakTitleInfoTextView.setText(mediaMetadata.title);
bind.trakArtistInfoTextView.setText(
mediaMetadata.artist != null
? mediaMetadata.artist
: mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO)
? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder))
: "");
String type = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type") : null;
boolean isRadio = Objects.equals(type, Constants.MEDIA_TYPE_RADIO);
if (isRadio) {
// For radio: always read from extras first (radioArtist, radioTitle, stationName)
// MediaMetadata.title/artist are formatted for notification
String stationName = mediaMetadata.extras != null
? mediaMetadata.extras.getString("stationName",
mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "")
: mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "";
String artist = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioArtist", "")
: "";
String title = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioTitle", "")
: "";
// Format: "Artist - Song" or fallback to title or station name
String mainTitle;
if (!android.text.TextUtils.isEmpty(artist) && !android.text.TextUtils.isEmpty(title)) {
mainTitle = artist + " - " + title;
} else if (!android.text.TextUtils.isEmpty(title)) {
mainTitle = title;
} else if (!android.text.TextUtils.isEmpty(artist)) {
mainTitle = artist;
} else {
mainTitle = stationName;
}
bind.trakTitleInfoTextView.setText(mainTitle);
bind.trakArtistInfoTextView.setText(stationName);
} else {
bind.trakTitleInfoTextView.setText(mediaMetadata.title);
bind.trakArtistInfoTextView.setText(
mediaMetadata.artist != null
? mediaMetadata.artist
: "");
}
if (mediaMetadata.extras != null) {
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 genreValue = mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder));
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)) {
genreLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_GENRE, genreValue);

View File

@@ -35,6 +35,7 @@ import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.TileSizeManager;
import com.cappielloantonio.tempo.viewmodel.AlbumCatalogueViewModel;
import java.util.ArrayList;
@@ -48,9 +49,9 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
private FragmentAlbumCatalogueBinding bind;
private MainActivity activity;
private AlbumCatalogueViewModel albumCatalogueViewModel;
private AlbumCatalogueAdapter albumAdapter;
private int spanCount = 2;
private int tileSpacing = 20;
private AlbumCatalogueAdapter albumAdapter;
private String currentSortOrder;
private List<com.cappielloantonio.tempo.subsonic.models.AlbumID3> originalAlbums;
@@ -92,9 +93,9 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
bind = FragmentAlbumCatalogueBinding.inflate(inflater, container, false);
View view = bind.getRoot();
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
spanCount = Preferences.getLandscapeItemsPerRow();
}
TileSizeManager.getInstance().calculateTileSize( requireContext() );
spanCount = TileSizeManager.getInstance().getTileSpanCount( requireContext() );
tileSpacing = TileSizeManager.getInstance().getTileSpacing( requireContext() );
initAppBar();
initAlbumCatalogueView();
@@ -140,7 +141,7 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
@SuppressLint("ClickableViewAccessibility")
private void initAlbumCatalogueView() {
bind.albumCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.albumCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 20, false));
bind.albumCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, tileSpacing, false));
bind.albumCatalogueRecyclerView.setHasFixedSize(true);
albumAdapter = new AlbumCatalogueAdapter(this, true);

View File

@@ -261,8 +261,10 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind.albumOtherInfoButton.setOnClickListener(v -> {
if (bind.albumDetailView.getVisibility() == View.GONE) {
bind.albumDetailView.setVisibility(View.VISIBLE);
bind.albumNameLabel.setMaxLines(Integer.MAX_VALUE);
} else if (bind.albumDetailView.getVisibility() == View.VISIBLE) {
bind.albumDetailView.setVisibility(View.GONE);
bind.albumNameLabel.setMaxLines(2);
}
});

View File

@@ -36,6 +36,7 @@ import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.ArtistCatalogueAdapter;
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.subsonic.models.ArtistID3;
@@ -51,7 +52,9 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
private ArtistCatalogueViewModel artistCatalogueViewModel;
private ArtistCatalogueAdapter artistAdapter;
private int spanCount = 2;
private int tileSpacing = 20;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
@@ -68,9 +71,9 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
bind = FragmentArtistCatalogueBinding.inflate(inflater, container, false);
View view = bind.getRoot();
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
spanCount = Preferences.getLandscapeItemsPerRow();
}
TileSizeManager.getInstance().calculateTileSize( requireContext() );
spanCount = TileSizeManager.getInstance().getTileSpanCount( requireContext() );
tileSpacing = TileSizeManager.getInstance().getTileSpacing( requireContext() );
initAppBar();
initArtistCatalogueView();
@@ -115,7 +118,7 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
@SuppressLint("ClickableViewAccessibility")
private void initArtistCatalogueView() {
bind.artistCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.artistCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 20, false));
bind.artistCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, tileSpacing, false));
bind.artistCatalogueRecyclerView.setHasFixedSize(true);
artistAdapter = new ArtistCatalogueAdapter(this);

View File

@@ -43,6 +43,7 @@ import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.TileSizeManager;
import com.cappielloantonio.tempo.viewmodel.ArtistPageViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@@ -65,6 +66,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private int spanCount = 2;
private int tileSpacing = 20;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
@@ -75,9 +77,9 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
artistPageViewModel = new ViewModelProvider(requireActivity()).get(ArtistPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
spanCount = Preferences.getLandscapeItemsPerRow();
}
TileSizeManager.getInstance().calculateTileSize( requireContext() );
spanCount = TileSizeManager.getInstance().getTileSpanCount( requireContext() );
tileSpacing = TileSizeManager.getInstance().getTileSpacing( requireContext() );
init(view);
initAppBar();
@@ -285,7 +287,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
private void initAlbumsView() {
bind.albumsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.albumsRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 20, false));
bind.albumsRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, tileSpacing, false));
bind.albumsRecyclerView.setHasFixedSize(true);
albumCatalogueAdapter = new AlbumCatalogueAdapter(this, false);
@@ -304,7 +306,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
private void initSimilarArtistsView() {
bind.similarArtistsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.similarArtistsRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 20, false));
bind.similarArtistsRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, tileSpacing, false));
bind.similarArtistsRecyclerView.setHasFixedSize(true);
artistCatalogueAdapter = new ArtistCatalogueAdapter(this);

View File

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

View File

@@ -21,18 +21,26 @@ import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.service.EqualizerManager
import com.cappielloantonio.tempo.service.BaseMediaService
import com.cappielloantonio.tempo.service.MediaService
import com.cappielloantonio.tempo.ui.activity.MainActivity
import com.cappielloantonio.tempo.util.Preferences
class EqualizerFragment : Fragment() {
private lateinit var activity: MainActivity
private var equalizerManager: EqualizerManager? = null
private lateinit var eqBandsContainer: LinearLayout
private lateinit var eqSwitch: Switch
private lateinit var resetButton: Button
private lateinit var safeSpace: Space
private val bandSeekBars = mutableListOf<SeekBar>()
private 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?) {
@@ -73,6 +81,8 @@ class EqualizerFragment : Fragment() {
)
receiverRegistered = true
}
val showBottomBar = !Preferences.getHideBottomNavbarOnPortrait()
activity.setBottomNavigationBarVisibility(showBottomBar)
}
override fun onStop() {

View File

@@ -34,6 +34,7 @@ import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.GenreCatalogueAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.TileSizeManager;
import com.cappielloantonio.tempo.viewmodel.GenreCatalogueViewModel;
@OptIn(markerClass = UnstableApi.class)
@@ -43,7 +44,9 @@ public class GenreCatalogueFragment extends Fragment implements ClickCallback {
private GenreCatalogueViewModel genreCatalogueViewModel;
private GenreCatalogueAdapter genreCatalogueAdapter;
private int spanCount = 2;
private int tileSpacing = 20;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
@@ -59,9 +62,9 @@ public class GenreCatalogueFragment extends Fragment implements ClickCallback {
View view = bind.getRoot();
genreCatalogueViewModel = new ViewModelProvider(requireActivity()).get(GenreCatalogueViewModel.class);
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
spanCount = Preferences.getLandscapeItemsPerRow();
}
TileSizeManager.getInstance().calculateGenreSize( requireContext() );
spanCount = TileSizeManager.getInstance().getGenreSpanCount( requireContext() );
tileSpacing = TileSizeManager.getInstance().getGenreSpacing( requireContext() );
init();
initAppBar();
@@ -105,7 +108,7 @@ public class GenreCatalogueFragment extends Fragment implements ClickCallback {
@SuppressLint("ClickableViewAccessibility")
private void initGenreCatalogueView() {
bind.genreCatalogueRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), spanCount));
bind.genreCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, 16, false));
bind.genreCatalogueRecyclerView.addItemDecoration(new GridItemDecoration(spanCount, tileSpacing, false));
bind.genreCatalogueRecyclerView.setHasFixedSize(true);
genreCatalogueAdapter = new GenreCatalogueAdapter(this);

View File

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

View File

@@ -64,6 +64,7 @@ import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.TileSizeManager;
import com.cappielloantonio.tempo.util.UIUtil;
import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
@@ -682,11 +683,12 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
private void initDiscoverSongSlideView() {
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);
bind.discoverSongViewPager.setAdapter(discoverSongAdapter);
bind.discoverSongViewPager.setOffscreenPageLimit(1);
bind.discoverSongRecyclerView.setAdapter(discoverSongAdapter);
homeViewModel.getDiscoverSongSample(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), songs -> {
MusicUtil.ratingFilter(songs);
@@ -699,8 +701,6 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
discoverSongAdapter.setItems(songs);
}
});
setSlideViewOffset(bind.discoverSongViewPager, 20, 16);
}
private void initSimilarSongView() {

View File

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

View File

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

View File

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

View File

@@ -7,9 +7,12 @@ import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.IBinder;
import android.text.TextUtils;
import android.util.Log;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.LinearLayout;
@@ -32,6 +35,10 @@ import androidx.media3.session.SessionToken;
import androidx.navigation.NavController;
import androidx.navigation.NavOptions;
import androidx.navigation.fragment.NavHostFragment;
import androidx.transition.ChangeBounds;
import androidx.transition.Slide;
import androidx.transition.TransitionManager;
import androidx.transition.TransitionSet;
import androidx.viewpager2.widget.ViewPager2;
import com.cappielloantonio.tempo.R;
@@ -55,7 +62,6 @@ import com.google.android.material.elevation.SurfaceColors;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -214,12 +220,53 @@ public class PlayerControllerFragment extends Fragment {
}
private void setMetadata(MediaMetadata mediaMetadata) {
String type = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type") : null;
if (Objects.equals(type, Constants.MEDIA_TYPE_RADIO)) {
// For radio: always read from extras first (radioArtist, radioTitle, stationName)
// MediaMetadata.title/artist are formatted for notification
String stationName = mediaMetadata.extras != null
? mediaMetadata.extras.getString("stationName",
mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "")
: mediaMetadata.artist != null ? String.valueOf(mediaMetadata.artist) : "";
String artist = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioArtist", "")
: "";
String title = mediaMetadata.extras != null
? mediaMetadata.extras.getString("radioTitle", "")
: "";
// Format: "Artist - Song" or fallback to title or station name
String mainTitle;
if (!TextUtils.isEmpty(artist) && !TextUtils.isEmpty(title)) {
mainTitle = artist + " - " + title;
} else if (!TextUtils.isEmpty(title)) {
mainTitle = title;
} else if (!TextUtils.isEmpty(artist)) {
mainTitle = artist;
} else {
mainTitle = stationName;
}
playerMediaTitleLabel.setText(mainTitle);
playerArtistNameLabel.setText(stationName);
playerMediaTitleLabel.setSelected(true);
playerArtistNameLabel.setSelected(true);
playerMediaTitleLabel.setVisibility(!TextUtils.isEmpty(mainTitle) ? View.VISIBLE : View.GONE);
playerArtistNameLabel.setVisibility(!TextUtils.isEmpty(stationName) ? View.VISIBLE : View.GONE);
updateAssetLinkChips(mediaMetadata);
return;
}
playerMediaTitleLabel.setText(String.valueOf(mediaMetadata.title));
playerArtistNameLabel.setText(
mediaMetadata.artist != null
? String.valueOf(mediaMetadata.artist)
: mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO)
? mediaMetadata.extras.getString("uri", getString(R.string.label_placeholder))
: "");
playerMediaTitleLabel.setSelected(true);
@@ -236,41 +283,80 @@ public class PlayerControllerFragment extends Fragment {
}
private void setMediaInfo(MediaMetadata mediaMetadata) {
boolean isLocal = false;
if (mediaBrowserListenableFuture != null && mediaBrowserListenableFuture.isDone()) {
try {
MediaBrowser browser = mediaBrowserListenableFuture.get();
if (browser != null && browser.getCurrentMediaItem() != null) {
android.net.Uri currentUri = browser.getCurrentMediaItem().requestMetadata.mediaUri;
if (currentUri != null) {
String scheme = currentUri.getScheme();
isLocal = "content".equals(scheme) || "file".equals(scheme);
}
}
} catch (Exception e) {
Log.e("DEBUG_PLAYER", "Error getting browser for UI update", e);
}
}
if (mediaMetadata.extras != null) {
String extension = mediaMetadata.extras.getString("suffix", getString(R.string.player_unknown_format));
String bitrate = mediaMetadata.extras.getInt("bitrate", 0) != 0 ? mediaMetadata.extras.getInt("bitrate", 0) + "kbps" : "Original";
String samplingRate = mediaMetadata.extras.getInt("samplingRate", 0) != 0 ? new DecimalFormat("0.#").format(mediaMetadata.extras.getInt("samplingRate", 0) / 1000.0) + "kHz" : "";
int rawBitrate = mediaMetadata.extras.getInt("bitrate", 0);
String bitrate = rawBitrate != 0 ? rawBitrate + "kbps" : "Original";
String samplingRate = mediaMetadata.extras.getInt("samplingRate", 0) != 0 ?
new java.text.DecimalFormat("0.#").format(mediaMetadata.extras.getInt("samplingRate", 0) / 1000.0) + "kHz" : "";
String bitDepth = mediaMetadata.extras.getInt("bitDepth", 0) != 0 ? mediaMetadata.extras.getInt("bitDepth", 0) + "b" : "";
playerMediaExtension.setText(extension);
if (bitrate.equals("Original")) {
if (bitrate.equals("Original") && !isLocal) {
playerMediaBitrate.setVisibility(View.GONE);
} else {
List<String> mediaQualityItems = new ArrayList<>();
if (!bitrate.trim().isEmpty()) mediaQualityItems.add(bitrate);
if (!bitDepth.trim().isEmpty()) mediaQualityItems.add(bitDepth);
if (!samplingRate.trim().isEmpty()) mediaQualityItems.add(samplingRate);
String mediaQuality = TextUtils.join("", mediaQualityItems);
playerMediaBitrate.setVisibility(View.VISIBLE);
playerMediaBitrate.setText(mediaQuality);
List<String> items = new ArrayList<>();
if (!bitrate.trim().isEmpty()) items.add(bitrate);
if (!bitDepth.trim().isEmpty()) items.add(bitDepth);
if (!samplingRate.trim().isEmpty()) items.add(samplingRate);
String mediaQuality = TextUtils.join("", items);
playerMediaBitrate.setVisibility(Preferences.getBitrateVisible() ? View.VISIBLE : View.GONE);
playerMediaBitrate.setText(isLocal ? mediaQuality : mediaQuality);
}
}
boolean isTranscodingExtension = !MusicUtil.getTranscodingFormatPreference().equals("raw");
boolean isTranscodingBitrate = !MusicUtil.getBitratePreference().equals("0");
if (!isLocal) {
boolean isTranscodingExtension = !MusicUtil.getTranscodingFormatPreference().equals("raw");
boolean isTranscodingBitrate = !MusicUtil.getBitratePreference().equals("0");
if (isTranscodingExtension || isTranscodingBitrate) {
playerMediaExtension.setText(MusicUtil.getTranscodingFormatPreference() + " (" + getString(R.string.player_transcoding) + ")");
playerMediaBitrate.setText(!MusicUtil.getBitratePreference().equals("0") ?
MusicUtil.getBitratePreference() + "kbps" : getString(R.string.player_transcoding_requested));
}
if (isTranscodingExtension || isTranscodingBitrate) {
playerMediaExtension.setText(MusicUtil.getTranscodingFormatPreference() + " (" + getString(R.string.player_transcoding) + ")");
playerMediaBitrate.setText(!MusicUtil.getBitratePreference().equals("0") ? MusicUtil.getBitratePreference() + "kbps" : getString(R.string.player_transcoding_requested));
}
playerTrackInfo.setOnClickListener(view -> {
TrackInfoDialog dialog = new TrackInfoDialog(mediaMetadata);
dialog.show(activity.getSupportFragmentManager(), null);
});
});
playerMediaExtension.setOnClickListener( v -> toggleBitrateVisibility() );
playerMediaBitrate.setOnClickListener(v -> toggleBitrateVisibility() );
}
private void toggleBitrateVisibility() {
ViewGroup parent = (ViewGroup) playerMediaBitrate.getParent();
TransitionSet transition = new TransitionSet()
.addTransition(new Slide(Gravity.START))
.addTransition(new ChangeBounds())
.setDuration(500)
.setInterpolator(new AccelerateDecelerateInterpolator());
TransitionManager.beginDelayedTransition(parent, transition);
playerMediaBitrate.setVisibility(Preferences.getBitrateVisible() ? View.GONE : View.VISIBLE);
Preferences.setBitrateVisible(!Preferences.getBitrateVisible());
}
private void updateAssetLinkChips(MediaMetadata mediaMetadata) {

View File

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

View File

@@ -26,16 +26,20 @@ import com.cappielloantonio.tempo.helper.recyclerview.CustomLinearSnapHelper;
import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Playlist;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter;
import com.cappielloantonio.tempo.ui.adapter.ArtistAdapter;
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.ui.adapter.PlaylistHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.SearchViewModel;
import com.cappielloantonio.tempo.subsonic.models.PlaylistWithSongs;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.Collections;
import java.util.List;
@UnstableApi
public class SearchFragment extends Fragment implements ClickCallback {
@@ -49,6 +53,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
private ArtistAdapter artistAdapter;
private AlbumAdapter albumAdapter;
private SongHorizontalAdapter songHorizontalAdapter;
private PlaylistHorizontalAdapter playlistHorizontalAdapter;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@@ -126,6 +131,12 @@ public class SearchFragment extends Fragment implements ClickCallback {
reapplyPlayback();
bind.searchResultTracksRecyclerView.setAdapter(songHorizontalAdapter);
bind.allsongsview.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.allsongsview.setHasFixedSize(true);
playlistHorizontalAdapter = new PlaylistHorizontalAdapter(this);
bind.allsongsview.setAdapter(playlistHorizontalAdapter);
}
private void initSearchView() {
@@ -216,13 +227,23 @@ public class SearchFragment extends Fragment implements ClickCallback {
public void search(String query) {
searchViewModel.setQuery(query);
bind.allSongs.setText(this.getView().getContext().getString(R.string.search_all_songs_loading));
playlistHorizontalAdapter.setItems(Collections.emptyList());
bind.searchBar.setText(query);
bind.searchView.hide();
performSearch(query);
}
public void updateUI(List<Playlist> allSongs) {
if (!allSongs.isEmpty()) {
playlistHorizontalAdapter.setItems(allSongs);
} else {
playlistHorizontalAdapter.setItems(Collections.emptyList());
}
bind.allSongs.setText(this.getView().getContext().getString(R.string.search_all_songs_play,String.valueOf(allSongs.getFirst().getName())));
}
private void performSearch(String query) {
searchViewModel.search3(query).observe(getViewLifecycleOwner(), result -> {
searchViewModel.search3(this, query).observe(getViewLifecycleOwner(), result -> {
if (bind != null) {
if (result.getArtists() != null) {
bind.searchArtistSector.setVisibility(!result.getArtists().isEmpty() ? View.VISIBLE : View.GONE);
@@ -281,6 +302,19 @@ public class SearchFragment extends Fragment implements ClickCallback {
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
}
@Override
public void onPlaylistClick(Bundle bundle) {
PlaylistWithSongs playlistWithSongs = bundle.getParcelable(Constants.PLAYLIST_OBJECT);
if (playlistWithSongs != null) {
MediaManager.startQueue(mediaBrowserListenableFuture, playlistWithSongs.getEntries(), 0);
}
}
@Override
public void onPlaylistLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.playlistBottomSheetDialog, bundle);
}
@Override
public void onAlbumClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.albumPageFragment, bundle);

View File

@@ -0,0 +1,603 @@
package com.cappielloantonio.tempo.ui.fragment;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.media.audiofx.AudioEffect;
import android.net.Uri;
import android.os.Bundle;
import android.os.IBinder;
import android.text.InputFilter;
import android.text.InputType;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.os.LocaleListCompat;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController;
import androidx.navigation.NavOptions;
import androidx.navigation.fragment.NavHostFragment;
import androidx.preference.EditTextPreference;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceCategory;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.SwitchPreference;
import com.cappielloantonio.tempo.BuildConfig;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.helper.ThemeHelper;
import com.cappielloantonio.tempo.interfaces.DialogClickCallback;
import com.cappielloantonio.tempo.interfaces.ScanCallback;
import com.cappielloantonio.tempo.service.EqualizerManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.dialog.DeleteDownloadStorageDialog;
import com.cappielloantonio.tempo.ui.dialog.DownloadStorageDialog;
import com.cappielloantonio.tempo.ui.dialog.StarredAlbumSyncDialog;
import com.cappielloantonio.tempo.ui.dialog.StarredArtistSyncDialog;
import com.cappielloantonio.tempo.ui.dialog.StarredSyncDialog;
import com.cappielloantonio.tempo.ui.dialog.StreamingCacheStorageDialog;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.UIUtil;
import com.cappielloantonio.tempo.viewmodel.SettingViewModel;
import java.util.Locale;
import java.util.Map;
@OptIn(markerClass = UnstableApi.class)
public class SettingsContainerFragment extends PreferenceFragmentCompat {
private static final String TAG = "SettingsFragment";
private MainActivity activity;
private SettingViewModel settingViewModel;
private ActivityResultLauncher<Intent> directoryPickerLauncher;
private MediaService.LocalBinder mediaServiceBinder;
private boolean isServiceBound = false;
private ActivityResultLauncher<Intent> equalizerResultLauncher;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
equalizerResultLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {}
);
if (!BuildConfig.FLAVOR.equals("tempus")) {
PreferenceCategory githubUpdateCategory = findPreference("settings_github_update_category_key");
if (githubUpdateCategory != null) {
getPreferenceScreen().removePreference(githubUpdateCategory);
}
}
directoryPickerLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK) {
Intent data = result.getData();
if (data != null) {
Uri uri = data.getData();
if (uri != null) {
requireContext().getContentResolver().takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
);
Preferences.setDownloadDirectoryUri(uri.toString());
ExternalAudioReader.refreshCache();
Toast.makeText(requireContext(), R.string.settings_download_folder_set, Toast.LENGTH_SHORT).show();
checkDownloadDirectory();
}
}
}
});
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
activity = (MainActivity) getActivity();
View view = super.onCreateView(inflater, container, savedInstanceState);
settingViewModel = new ViewModelProvider(requireActivity()).get(SettingViewModel.class);
if (view != null) {
getListView().setPadding(0, 0, 0, (int) getResources().getDimension(R.dimen.global_padding_bottom));
}
return view;
}
@Override
public void onStart() {
super.onStart();
activity.setBottomNavigationBarVisibility(false);
activity.setBottomSheetVisibility(false);
}
@Override
public void onResume() {
super.onResume();
checkSystemEqualizer();
checkCacheStorage();
checkStorage();
checkDownloadDirectory();
setStreamingCacheSize();
setAppLanguage();
setVersion();
setNetorkPingTimeoutBase();
actionLogout();
actionScan();
actionSyncStarredAlbums();
actionSyncStarredTracks();
actionSyncStarredArtists();
actionChangeStreamingCacheStorage();
actionChangeDownloadStorage();
actionSetDownloadDirectory();
actionDeleteDownloadStorage();
actionKeepScreenOn();
actionAutoDownloadLyrics();
actionMiniPlayerHeart();
bindMediaService();
actionAppEqualizer();
}
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
setPreferencesFromResource(R.xml.global_preferences, rootKey);
ListPreference themePreference = findPreference(Preferences.THEME);
if (themePreference != null) {
themePreference.setOnPreferenceChangeListener(
(preference, newValue) -> {
String themeOption = (String) newValue;
ThemeHelper.applyTheme(themeOption);
return true;
});
}
}
private void checkSystemEqualizer() {
Preference equalizer = findPreference("system_equalizer");
if (equalizer == null) return;
Intent intent = new Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL);
if ((intent.resolveActivity(requireActivity().getPackageManager()) != null)) {
equalizer.setOnPreferenceClickListener(preference -> {
equalizerResultLauncher.launch(intent);
return true;
});
} else {
equalizer.setVisible(false);
}
}
private void checkCacheStorage() {
Preference storage = findPreference("streaming_cache_storage");
if (storage == null) return;
try {
if (requireContext().getExternalFilesDirs(null)[1] == null) {
storage.setVisible(false);
} else {
storage.setSummary(Preferences.getStreamingCacheStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
}
} catch (Exception exception) {
storage.setVisible(false);
}
}
private void checkStorage() {
Preference storage = findPreference("download_storage");
if (storage == null) return;
try {
if (requireContext().getExternalFilesDirs(null)[1] == null) {
storage.setVisible(false);
} else {
int pref = Preferences.getDownloadStoragePreference();
if (pref == 0) {
storage.setSummary(R.string.download_storage_internal_dialog_negative_button);
} else if (pref == 1) {
storage.setSummary(R.string.download_storage_external_dialog_positive_button);
} else {
storage.setSummary(R.string.download_storage_directory_dialog_neutral_button);
}
}
} catch (Exception exception) {
storage.setVisible(false);
}
}
private void checkDownloadDirectory() {
Preference storage = findPreference("download_storage");
Preference directory = findPreference("set_download_directory");
if (directory == null) return;
String current = Preferences.getDownloadDirectoryUri();
if (current != null) {
if (storage != null) storage.setVisible(false);
directory.setVisible(true);
directory.setIcon(R.drawable.ic_close);
directory.setTitle(R.string.settings_clear_download_folder);
directory.setSummary(current);
} else {
if (storage != null) storage.setVisible(true);
if (Preferences.getDownloadStoragePreference() == 2) {
directory.setVisible(true);
directory.setIcon(R.drawable.ic_folder);
directory.setTitle(R.string.settings_set_download_folder);
directory.setSummary(R.string.settings_choose_download_folder);
} else {
directory.setVisible(false);
}
}
}
private void setNetorkPingTimeoutBase() {
EditTextPreference networkPingTimeoutBase = findPreference("network_ping_timeout_base");
if (networkPingTimeoutBase != null) {
networkPingTimeoutBase.setSummaryProvider(EditTextPreference.SimpleSummaryProvider.getInstance());
networkPingTimeoutBase.setOnBindEditTextListener(editText -> {
editText.setInputType(InputType.TYPE_CLASS_NUMBER);
editText.setFilters(new InputFilter[]{ (source, start, end, dest, dstart, dend) -> {
for (int i = start; i < end; i++) {
if (!Character.isDigit(source.charAt(i))) {
return "";
}
}
return null;
}});
});
networkPingTimeoutBase.setOnPreferenceChangeListener((preference, newValue) -> {
String input = (String) newValue;
return input != null && !input.isEmpty();
});
}
}
private void setStreamingCacheSize() {
ListPreference streamingCachePreference = findPreference("streaming_cache_size");
if (streamingCachePreference != null) {
streamingCachePreference.setSummaryProvider(new Preference.SummaryProvider<ListPreference>() {
@Nullable
@Override
public CharSequence provideSummary(@NonNull ListPreference preference) {
CharSequence entry = preference.getEntry();
if (entry == null) return null;
long currentSizeMb = DownloadUtil.getStreamingCacheSize(requireActivity()) / (1024 * 1024);
return getString(R.string.settings_summary_streaming_cache_size, entry, String.valueOf(currentSizeMb));
}
});
}
}
private void setAppLanguage() {
ListPreference localePref = (ListPreference) findPreference("language");
Map<String, String> locales = UIUtil.getLangPreferenceDropdownEntries(requireContext());
CharSequence[] entries = locales.keySet().toArray(new CharSequence[locales.size()]);
CharSequence[] entryValues = locales.values().toArray(new CharSequence[locales.size()]);
localePref.setEntries(entries);
localePref.setEntryValues(entryValues);
String value = localePref.getValue();
if ("default".equals(value)) {
localePref.setSummary(requireContext().getString(R.string.settings_system_language));
} else {
localePref.setSummary(Locale.forLanguageTag(value).getDisplayName());
}
localePref.setOnPreferenceChangeListener((preference, newValue) -> {
if ("default".equals(newValue)) {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList());
preference.setSummary(requireContext().getString(R.string.settings_system_language));
} else {
LocaleListCompat appLocale = LocaleListCompat.forLanguageTags((String) newValue);
AppCompatDelegate.setApplicationLocales(appLocale);
preference.setSummary(Locale.forLanguageTag((String) newValue).getDisplayName());
}
return true;
});
}
private void setVersion() {
findPreference("version").setSummary(BuildConfig.VERSION_NAME);
}
private void actionLogout() {
findPreference("logout").setOnPreferenceClickListener(preference -> {
activity.quit();
return true;
});
}
private void actionScan() {
findPreference("scan_library").setOnPreferenceClickListener(preference -> {
settingViewModel.launchScan(new ScanCallback() {
@Override
public void onError(Exception exception) {
findPreference("scan_library").setSummary(exception.getMessage());
}
@Override
public void onSuccess(boolean isScanning, long count) {
findPreference("scan_library").setSummary(getString(R.string.settings_scan_result, count));
if (isScanning) getScanStatus();
}
});
return true;
});
}
private void actionSyncStarredTracks() {
findPreference("sync_starred_tracks_for_offline_use").setOnPreferenceChangeListener((preference, newValue) -> {
if (newValue instanceof Boolean) {
if ((Boolean) newValue) {
StarredSyncDialog dialog = new StarredSyncDialog(() -> {
((SwitchPreference)preference).setChecked(false);
});
dialog.show(activity.getSupportFragmentManager(), null);
}
}
return true;
});
}
private void actionSyncStarredAlbums() {
findPreference("sync_starred_albums_for_offline_use").setOnPreferenceChangeListener((preference, newValue) -> {
if (newValue instanceof Boolean) {
if ((Boolean) newValue) {
StarredAlbumSyncDialog dialog = new StarredAlbumSyncDialog(() -> {
((SwitchPreference)preference).setChecked(false);
});
dialog.show(activity.getSupportFragmentManager(), null);
}
}
return true;
});
}
private void actionSyncStarredArtists() {
findPreference("sync_starred_artists_for_offline_use").setOnPreferenceChangeListener((preference, newValue) -> {
if (newValue instanceof Boolean) {
if ((Boolean) newValue) {
StarredArtistSyncDialog dialog = new StarredArtistSyncDialog(() -> {
((SwitchPreference)preference).setChecked(false);
});
dialog.show(activity.getSupportFragmentManager(), null);
}
}
return true;
});
}
private void actionChangeStreamingCacheStorage() {
findPreference("streaming_cache_storage").setOnPreferenceClickListener(preference -> {
StreamingCacheStorageDialog dialog = new StreamingCacheStorageDialog(new DialogClickCallback() {
@Override
public void onPositiveClick() {
findPreference("streaming_cache_storage").setSummary(R.string.streaming_cache_storage_external_dialog_positive_button);
}
@Override
public void onNegativeClick() {
findPreference("streaming_cache_storage").setSummary(R.string.streaming_cache_storage_internal_dialog_negative_button);
}
});
dialog.show(activity.getSupportFragmentManager(), null);
return true;
});
}
private void actionChangeDownloadStorage() {
findPreference("download_storage").setOnPreferenceClickListener(preference -> {
DownloadStorageDialog dialog = new DownloadStorageDialog(new DialogClickCallback() {
@Override
public void onPositiveClick() {
findPreference("download_storage").setSummary(R.string.download_storage_external_dialog_positive_button);
checkDownloadDirectory();
}
@Override
public void onNegativeClick() {
findPreference("download_storage").setSummary(R.string.download_storage_internal_dialog_negative_button);
checkDownloadDirectory();
}
@Override
public void onNeutralClick() {
findPreference("download_storage").setSummary(R.string.download_storage_directory_dialog_neutral_button);
checkDownloadDirectory();
}
});
dialog.show(activity.getSupportFragmentManager(), null);
return true;
});
}
private void actionSetDownloadDirectory() {
Preference pref = findPreference("set_download_directory");
if (pref != null) {
pref.setOnPreferenceClickListener(preference -> {
String current = Preferences.getDownloadDirectoryUri();
if (current != null) {
Preferences.setDownloadDirectoryUri(null);
Preferences.setDownloadStoragePreference(0);
ExternalAudioReader.refreshCache();
Toast.makeText(requireContext(), R.string.settings_download_folder_cleared, Toast.LENGTH_SHORT).show();
checkStorage();
checkDownloadDirectory();
} else {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
directoryPickerLauncher.launch(intent);
}
return true;
});
}
}
private void actionDeleteDownloadStorage() {
findPreference("delete_download_storage").setOnPreferenceClickListener(preference -> {
DeleteDownloadStorageDialog dialog = new DeleteDownloadStorageDialog();
dialog.show(activity.getSupportFragmentManager(), null);
return true;
});
}
private void actionMiniPlayerHeart() {
SwitchPreference preference = findPreference("mini_shuffle_button_visibility");
if (preference == null) {
return;
}
preference.setChecked(Preferences.showShuffleInsteadOfHeart());
preference.setOnPreferenceChangeListener((pref, newValue) -> {
if (newValue instanceof Boolean) {
Preferences.setShuffleInsteadOfHeart((Boolean) newValue);
}
return true;
});
}
private void actionAutoDownloadLyrics() {
SwitchPreference preference = findPreference("auto_download_lyrics");
if (preference == null) {
return;
}
preference.setChecked(Preferences.isAutoDownloadLyricsEnabled());
preference.setOnPreferenceChangeListener((pref, newValue) -> {
if (newValue instanceof Boolean) {
Preferences.setAutoDownloadLyricsEnabled((Boolean) newValue);
}
return true;
});
}
private void getScanStatus() {
settingViewModel.getScanStatus(new ScanCallback() {
@Override
public void onError(Exception exception) {
findPreference("scan_library").setSummary(exception.getMessage());
}
@Override
public void onSuccess(boolean isScanning, long count) {
findPreference("scan_library").setSummary(getString(R.string.settings_scan_result, count));
if (isScanning) getScanStatus();
}
});
}
private void actionKeepScreenOn() {
findPreference("always_on_display").setOnPreferenceChangeListener((preference, newValue) -> {
if (newValue instanceof Boolean) {
if ((Boolean) newValue) {
activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} else {
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
return true;
});
}
private final ServiceConnection serviceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
mediaServiceBinder = (MediaService.LocalBinder) service;
isServiceBound = true;
checkEqualizerBands();
}
@Override
public void onServiceDisconnected(ComponentName name) {
mediaServiceBinder = null;
isServiceBound = false;
}
};
private void bindMediaService() {
Intent intent = new Intent(requireActivity(), MediaService.class);
intent.setAction(MediaService.ACTION_BIND_EQUALIZER);
requireActivity().bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
isServiceBound = true;
}
private void checkEqualizerBands() {
if (mediaServiceBinder != null) {
EqualizerManager eqManager = mediaServiceBinder.getEqualizerManager();
short numBands = eqManager.getNumberOfBands();
Preference appEqualizer = findPreference("app_equalizer");
if (appEqualizer != null) {
appEqualizer.setVisible(numBands > 0);
}
}
}
private void actionAppEqualizer() {
Preference appEqualizer = findPreference("app_equalizer");
if (appEqualizer != null) {
appEqualizer.setOnPreferenceClickListener(preference -> {
NavController navController = NavHostFragment.findNavController(this);
NavOptions navOptions = new NavOptions.Builder()
.setLaunchSingleTop(true)
.setPopUpTo(R.id.equalizerFragment, true)
.build();
activity.setBottomNavigationBarVisibility(true);
activity.setBottomSheetVisibility(true);
navController.navigate(R.id.equalizerFragment, null, navOptions);
return true;
});
}
}
@Override
public void onPause() {
super.onPause();
if (isServiceBound) {
requireActivity().unbindService(serviceConnection);
isServiceBound = false;
}
}
}

View File

@@ -1,128 +1,61 @@
package com.cappielloantonio.tempo.ui.fragment;
import android.app.Activity;
import android.content.Context;
import android.content.ComponentName;
import android.content.Intent;
import android.content.ServiceConnection;
import android.media.audiofx.AudioEffect;
import android.net.Uri;
import static com.google.android.material.internal.ViewUtils.hideKeyboard;
import android.os.Bundle;
import android.os.IBinder;
import android.text.InputFilter;
import android.text.InputType;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.os.LocaleListCompat;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController;
import androidx.navigation.NavOptions;
import androidx.navigation.fragment.NavHostFragment;
import androidx.preference.EditTextPreference;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceCategory;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.SwitchPreference;
import androidx.fragment.app.Fragment;
import com.cappielloantonio.tempo.BuildConfig;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.helper.ThemeHelper;
import com.cappielloantonio.tempo.interfaces.DialogClickCallback;
import com.cappielloantonio.tempo.interfaces.ScanCallback;
import com.cappielloantonio.tempo.service.EqualizerManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.databinding.FragmentSettingsBinding;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.dialog.DeleteDownloadStorageDialog;
import com.cappielloantonio.tempo.ui.dialog.DownloadStorageDialog;
import com.cappielloantonio.tempo.ui.dialog.StarredSyncDialog;
import com.cappielloantonio.tempo.ui.dialog.StarredAlbumSyncDialog;
import com.cappielloantonio.tempo.ui.dialog.StarredArtistSyncDialog;
import com.cappielloantonio.tempo.ui.dialog.StreamingCacheStorageDialog;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.UIUtil;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.viewmodel.SettingViewModel;
import java.util.Locale;
import java.util.Map;
@OptIn(markerClass = UnstableApi.class)
public class SettingsFragment extends PreferenceFragmentCompat {
private static final String TAG = "SettingsFragment";
public class SettingsFragment extends Fragment {
private MainActivity activity;
private SettingViewModel settingViewModel;
private ActivityResultLauncher<Intent> equalizerResultLauncher;
private ActivityResultLauncher<Intent> directoryPickerLauncher;
private MediaService.LocalBinder mediaServiceBinder;
private boolean isServiceBound = false;
private FragmentSettingsBinding bind;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
equalizerResultLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {}
);
activity = (MainActivity) getActivity();
if (!BuildConfig.FLAVOR.equals("tempus")) {
PreferenceCategory githubUpdateCategory = findPreference("settings_github_update_category_key");
if (githubUpdateCategory != null) {
getPreferenceScreen().removePreference(githubUpdateCategory);
}
}
directoryPickerLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK) {
Intent data = result.getData();
if (data != null) {
Uri uri = data.getData();
if (uri != null) {
requireContext().getContentResolver().takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
);
Preferences.setDownloadDirectoryUri(uri.toString());
ExternalAudioReader.refreshCache();
Toast.makeText(requireContext(), R.string.settings_download_folder_set, Toast.LENGTH_SHORT).show();
checkDownloadDirectory();
}
}
}
});
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
activity = (MainActivity) getActivity();
bind = FragmentSettingsBinding.inflate(inflater,container,false);
View view = bind.getRoot();
View view = super.onCreateView(inflater, container, savedInstanceState);
settingViewModel = new ViewModelProvider(requireActivity()).get(SettingViewModel.class);
if (view != null) {
getListView().setPadding(0, 0, 0, (int) getResources().getDimension(R.dimen.global_padding_bottom));
}
initAppBar();
return view;
}
@Override
public void onViewCreated(@NonNull View view,
@Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
// Add the PreferenceFragment only the first time
if (savedInstanceState == null) {
SettingsContainerFragment prefFragment = new SettingsContainerFragment();
// Use the child fragment manager so the PreferenceFragment is scoped to this fragment
getChildFragmentManager()
.beginTransaction()
.replace(R.id.settings_container, prefFragment)
.setReorderingAllowed(true) // optional but recommended
.commit();
}
}
@Override
@@ -130,479 +63,25 @@ public class SettingsFragment extends PreferenceFragmentCompat {
super.onStart();
activity.setBottomNavigationBarVisibility(false);
activity.setBottomSheetVisibility(false);
}
@Override
public void onResume() {
super.onResume();
checkSystemEqualizer();
checkCacheStorage();
checkStorage();
checkDownloadDirectory();
setStreamingCacheSize();
setAppLanguage();
setVersion();
setNetorkPingTimeoutBase();
actionLogout();
actionScan();
actionSyncStarredAlbums();
actionSyncStarredTracks();
actionSyncStarredArtists();
actionChangeStreamingCacheStorage();
actionChangeDownloadStorage();
actionSetDownloadDirectory();
actionDeleteDownloadStorage();
actionKeepScreenOn();
actionAutoDownloadLyrics();
actionMiniPlayerHeart();
bindMediaService();
actionAppEqualizer();
activity.setNavigationDrawerLock(true);
activity.setSystemBarsVisibility(!activity.isLandscape);
}
@Override
public void onStop() {
super.onStop();
activity.setBottomSheetVisibility(true);
}
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
setPreferencesFromResource(R.xml.global_preferences, rootKey);
ListPreference themePreference = findPreference(Preferences.THEME);
if (themePreference != null) {
themePreference.setOnPreferenceChangeListener(
(preference, newValue) -> {
String themeOption = (String) newValue;
ThemeHelper.applyTheme(themeOption);
return true;
});
if (activity.isLandscape) {
activity.setNavigationDrawerLock(false);
} else if (Preferences.getEnableDrawerOnPortrait()) {
activity.setNavigationDrawerLock(false);
}
}
private void checkSystemEqualizer() {
Preference equalizer = findPreference("system_equalizer");
if (equalizer == null) return;
Intent intent = new Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL);
if ((intent.resolveActivity(requireActivity().getPackageManager()) != null)) {
equalizer.setOnPreferenceClickListener(preference -> {
equalizerResultLauncher.launch(intent);
return true;
});
} else {
equalizer.setVisible(false);
}
}
private void checkCacheStorage() {
Preference storage = findPreference("streaming_cache_storage");
if (storage == null) return;
try {
if (requireContext().getExternalFilesDirs(null)[1] == null) {
storage.setVisible(false);
} else {
storage.setSummary(Preferences.getStreamingCacheStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
}
} catch (Exception exception) {
storage.setVisible(false);
}
}
private void checkStorage() {
Preference storage = findPreference("download_storage");
if (storage == null) return;
try {
if (requireContext().getExternalFilesDirs(null)[1] == null) {
storage.setVisible(false);
} else {
int pref = Preferences.getDownloadStoragePreference();
if (pref == 0) {
storage.setSummary(R.string.download_storage_internal_dialog_negative_button);
} else if (pref == 1) {
storage.setSummary(R.string.download_storage_external_dialog_positive_button);
} else {
storage.setSummary(R.string.download_storage_directory_dialog_neutral_button);
}
}
} catch (Exception exception) {
storage.setVisible(false);
}
}
private void checkDownloadDirectory() {
Preference storage = findPreference("download_storage");
Preference directory = findPreference("set_download_directory");
if (directory == null) return;
String current = Preferences.getDownloadDirectoryUri();
if (current != null) {
if (storage != null) storage.setVisible(false);
directory.setVisible(true);
directory.setIcon(R.drawable.ic_close);
directory.setTitle(R.string.settings_clear_download_folder);
directory.setSummary(current);
} else {
if (storage != null) storage.setVisible(true);
if (Preferences.getDownloadStoragePreference() == 2) {
directory.setVisible(true);
directory.setIcon(R.drawable.ic_folder);
directory.setTitle(R.string.settings_set_download_folder);
directory.setSummary(R.string.settings_choose_download_folder);
} else {
directory.setVisible(false);
}
}
}
private void setNetorkPingTimeoutBase() {
EditTextPreference networkPingTimeoutBase = findPreference("network_ping_timeout_base");
if (networkPingTimeoutBase != null) {
networkPingTimeoutBase.setSummaryProvider(EditTextPreference.SimpleSummaryProvider.getInstance());
networkPingTimeoutBase.setOnBindEditTextListener(editText -> {
editText.setInputType(InputType.TYPE_CLASS_NUMBER);
editText.setFilters(new InputFilter[]{ (source, start, end, dest, dstart, dend) -> {
for (int i = start; i < end; i++) {
if (!Character.isDigit(source.charAt(i))) {
return "";
}
}
return null;
}});
private void initAppBar() {
bind.settingsToolbar.setNavigationOnClickListener(v -> {
activity.navController.navigateUp();
});
networkPingTimeoutBase.setOnPreferenceChangeListener((preference, newValue) -> {
String input = (String) newValue;
return input != null && !input.isEmpty();
});
}
}
private void setStreamingCacheSize() {
ListPreference streamingCachePreference = findPreference("streaming_cache_size");
if (streamingCachePreference != null) {
streamingCachePreference.setSummaryProvider(new Preference.SummaryProvider<ListPreference>() {
@Nullable
@Override
public CharSequence provideSummary(@NonNull ListPreference preference) {
CharSequence entry = preference.getEntry();
if (entry == null) return null;
long currentSizeMb = DownloadUtil.getStreamingCacheSize(requireActivity()) / (1024 * 1024);
return getString(R.string.settings_summary_streaming_cache_size, entry, String.valueOf(currentSizeMb));
}
});
}
}
private void setAppLanguage() {
ListPreference localePref = (ListPreference) findPreference("language");
Map<String, String> locales = UIUtil.getLangPreferenceDropdownEntries(requireContext());
CharSequence[] entries = locales.keySet().toArray(new CharSequence[locales.size()]);
CharSequence[] entryValues = locales.values().toArray(new CharSequence[locales.size()]);
localePref.setEntries(entries);
localePref.setEntryValues(entryValues);
String value = localePref.getValue();
if ("default".equals(value)) {
localePref.setSummary(requireContext().getString(R.string.settings_system_language));
} else {
localePref.setSummary(Locale.forLanguageTag(value).getDisplayName());
}
localePref.setOnPreferenceChangeListener((preference, newValue) -> {
if ("default".equals(newValue)) {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList());
preference.setSummary(requireContext().getString(R.string.settings_system_language));
} else {
LocaleListCompat appLocale = LocaleListCompat.forLanguageTags((String) newValue);
AppCompatDelegate.setApplicationLocales(appLocale);
preference.setSummary(Locale.forLanguageTag((String) newValue).getDisplayName());
}
return true;
});
}
private void setVersion() {
findPreference("version").setSummary(BuildConfig.VERSION_NAME);
}
private void actionLogout() {
findPreference("logout").setOnPreferenceClickListener(preference -> {
activity.quit();
return true;
});
}
private void actionScan() {
findPreference("scan_library").setOnPreferenceClickListener(preference -> {
settingViewModel.launchScan(new ScanCallback() {
@Override
public void onError(Exception exception) {
findPreference("scan_library").setSummary(exception.getMessage());
}
@Override
public void onSuccess(boolean isScanning, long count) {
findPreference("scan_library").setSummary(getString(R.string.settings_scan_result, count));
if (isScanning) getScanStatus();
}
});
return true;
});
}
private void actionSyncStarredTracks() {
findPreference("sync_starred_tracks_for_offline_use").setOnPreferenceChangeListener((preference, newValue) -> {
if (newValue instanceof Boolean) {
if ((Boolean) newValue) {
StarredSyncDialog dialog = new StarredSyncDialog(() -> {
((SwitchPreference)preference).setChecked(false);
});
dialog.show(activity.getSupportFragmentManager(), null);
}
}
return true;
});
}
private void actionSyncStarredAlbums() {
findPreference("sync_starred_albums_for_offline_use").setOnPreferenceChangeListener((preference, newValue) -> {
if (newValue instanceof Boolean) {
if ((Boolean) newValue) {
StarredAlbumSyncDialog dialog = new StarredAlbumSyncDialog(() -> {
((SwitchPreference)preference).setChecked(false);
});
dialog.show(activity.getSupportFragmentManager(), null);
}
}
return true;
});
}
private void actionSyncStarredArtists() {
findPreference("sync_starred_artists_for_offline_use").setOnPreferenceChangeListener((preference, newValue) -> {
if (newValue instanceof Boolean) {
if ((Boolean) newValue) {
StarredArtistSyncDialog dialog = new StarredArtistSyncDialog(() -> {
((SwitchPreference)preference).setChecked(false);
});
dialog.show(activity.getSupportFragmentManager(), null);
}
}
return true;
});
}
private void actionChangeStreamingCacheStorage() {
findPreference("streaming_cache_storage").setOnPreferenceClickListener(preference -> {
StreamingCacheStorageDialog dialog = new StreamingCacheStorageDialog(new DialogClickCallback() {
@Override
public void onPositiveClick() {
findPreference("streaming_cache_storage").setSummary(R.string.streaming_cache_storage_external_dialog_positive_button);
}
@Override
public void onNegativeClick() {
findPreference("streaming_cache_storage").setSummary(R.string.streaming_cache_storage_internal_dialog_negative_button);
}
});
dialog.show(activity.getSupportFragmentManager(), null);
return true;
});
}
private void actionChangeDownloadStorage() {
findPreference("download_storage").setOnPreferenceClickListener(preference -> {
DownloadStorageDialog dialog = new DownloadStorageDialog(new DialogClickCallback() {
@Override
public void onPositiveClick() {
findPreference("download_storage").setSummary(R.string.download_storage_external_dialog_positive_button);
checkDownloadDirectory();
}
@Override
public void onNegativeClick() {
findPreference("download_storage").setSummary(R.string.download_storage_internal_dialog_negative_button);
checkDownloadDirectory();
}
@Override
public void onNeutralClick() {
findPreference("download_storage").setSummary(R.string.download_storage_directory_dialog_neutral_button);
checkDownloadDirectory();
}
});
dialog.show(activity.getSupportFragmentManager(), null);
return true;
});
}
private void actionSetDownloadDirectory() {
Preference pref = findPreference("set_download_directory");
if (pref != null) {
pref.setOnPreferenceClickListener(preference -> {
String current = Preferences.getDownloadDirectoryUri();
if (current != null) {
Preferences.setDownloadDirectoryUri(null);
Preferences.setDownloadStoragePreference(0);
ExternalAudioReader.refreshCache();
Toast.makeText(requireContext(), R.string.settings_download_folder_cleared, Toast.LENGTH_SHORT).show();
checkStorage();
checkDownloadDirectory();
} else {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
directoryPickerLauncher.launch(intent);
}
return true;
});
}
}
private void actionDeleteDownloadStorage() {
findPreference("delete_download_storage").setOnPreferenceClickListener(preference -> {
DeleteDownloadStorageDialog dialog = new DeleteDownloadStorageDialog();
dialog.show(activity.getSupportFragmentManager(), null);
return true;
});
}
private void actionMiniPlayerHeart() {
SwitchPreference preference = findPreference("mini_shuffle_button_visibility");
if (preference == null) {
return;
}
preference.setChecked(Preferences.showShuffleInsteadOfHeart());
preference.setOnPreferenceChangeListener((pref, newValue) -> {
if (newValue instanceof Boolean) {
Preferences.setShuffleInsteadOfHeart((Boolean) newValue);
}
return true;
});
}
private void actionAutoDownloadLyrics() {
SwitchPreference preference = findPreference("auto_download_lyrics");
if (preference == null) {
return;
}
preference.setChecked(Preferences.isAutoDownloadLyricsEnabled());
preference.setOnPreferenceChangeListener((pref, newValue) -> {
if (newValue instanceof Boolean) {
Preferences.setAutoDownloadLyricsEnabled((Boolean) newValue);
}
return true;
});
}
private void getScanStatus() {
settingViewModel.getScanStatus(new ScanCallback() {
@Override
public void onError(Exception exception) {
findPreference("scan_library").setSummary(exception.getMessage());
}
@Override
public void onSuccess(boolean isScanning, long count) {
findPreference("scan_library").setSummary(getString(R.string.settings_scan_result, count));
if (isScanning) getScanStatus();
}
});
}
private void actionKeepScreenOn() {
findPreference("always_on_display").setOnPreferenceChangeListener((preference, newValue) -> {
if (newValue instanceof Boolean) {
if ((Boolean) newValue) {
activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
} else {
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
return true;
});
}
private final ServiceConnection serviceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
mediaServiceBinder = (MediaService.LocalBinder) service;
isServiceBound = true;
checkEqualizerBands();
}
@Override
public void onServiceDisconnected(ComponentName name) {
mediaServiceBinder = null;
isServiceBound = false;
}
};
private void bindMediaService() {
Intent intent = new Intent(requireActivity(), MediaService.class);
intent.setAction(MediaService.ACTION_BIND_EQUALIZER);
requireActivity().bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
isServiceBound = true;
}
private void checkEqualizerBands() {
if (mediaServiceBinder != null) {
EqualizerManager eqManager = mediaServiceBinder.getEqualizerManager();
short numBands = eqManager.getNumberOfBands();
Preference appEqualizer = findPreference("app_equalizer");
if (appEqualizer != null) {
appEqualizer.setVisible(numBands > 0);
}
}
}
private void actionAppEqualizer() {
Preference appEqualizer = findPreference("app_equalizer");
if (appEqualizer != null) {
appEqualizer.setOnPreferenceClickListener(preference -> {
NavController navController = NavHostFragment.findNavController(this);
NavOptions navOptions = new NavOptions.Builder()
.setLaunchSingleTop(true)
.setPopUpTo(R.id.equalizerFragment, true)
.build();
activity.setBottomNavigationBarVisibility(true);
activity.setBottomSheetVisibility(true);
navController.navigate(R.id.equalizerFragment, null, navOptions);
return true;
});
}
}
@Override
public void onPause() {
super.onPause();
if (isServiceBound) {
requireActivity().unbindService(serviceConnection);
isServiceBound = false;
}
}
}

View File

@@ -299,4 +299,4 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
homeViewModel.refreshShares(requireActivity());
}
}
}

View File

@@ -0,0 +1,112 @@
package com.cappielloantonio.tempo.ui.fragment.bottomsheetdialog;
import android.content.ComponentName;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.media3.session.SessionToken;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.PlaylistWithSongs;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import com.google.common.util.concurrent.ListenableFuture;
@UnstableApi
public class PlaylistBottomSheetDialog extends BottomSheetDialogFragment implements View.OnClickListener {
private PlaylistWithSongs playlist;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private static final String TAG = "PlaylistBottomSheetDialog";
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.bottom_sheet_playlist_dialog, container, false);
playlist = requireArguments().getParcelable(Constants.PLAYLIST_OBJECT);
init(view);
return view;
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
}
@Override
public void onStart() {
super.onStart();
initializeMediaBrowser();
}
@Override
public void onStop() {
releaseMediaBrowser();
super.onStop();
}
private void init(View view) {
ImageView coverPlaylist = view.findViewById(R.id.playlist_cover_image_view);
CustomGlideRequest.Builder
.from(view.getContext(), playlist.getCoverArtId(), CustomGlideRequest.ResourceType.Playlist)
.build()
.into(coverPlaylist);
TextView titlePlaylist = view.findViewById(R.id.playlist_title_text_view);
titlePlaylist.setText(playlist.getName());
titlePlaylist.setSelected(true);
TextView countPlaylist = view.findViewById(R.id.playlist_count_text_view);
countPlaylist.setText(view.getContext().getString(R.string.playlist_counted_tracks, playlist.getSongCount(), MusicUtil.getReadableDurationString(playlist.getDuration(), false)));
TextView playNext = view.findViewById(R.id.play_next_text_view);
playNext.setOnClickListener(v -> {
MediaManager.enqueue(mediaBrowserListenableFuture, playlist.getEntries(), true);
((MainActivity) requireActivity()).setBottomSheetInPeek(true);
dismissBottomSheet();
});
TextView addToQueue = view.findViewById(R.id.add_to_queue_text_view);
addToQueue.setOnClickListener(v -> {
MediaManager.enqueue(mediaBrowserListenableFuture, playlist.getEntries(), false);
((MainActivity) requireActivity()).setBottomSheetInPeek(true);
dismissBottomSheet();
});
}
@Override
public void onClick(View v) {
dismissBottomSheet();
}
private void dismissBottomSheet() {
dismiss();
}
private void initializeMediaBrowser() {
mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync();
}
private void releaseMediaBrowser() {
MediaBrowser.releaseFuture(mediaBrowserListenableFuture);
}
}

View File

@@ -229,6 +229,34 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
});
updateDownloadButtons();
String playlistId = requireArguments().getString(Constants.PLAYLIST_ID);
int itemPosition = requireArguments().getInt(Constants.ITEM_POSITION, -1);
TextView removeFromPlaylist = view.findViewById(R.id.remove_from_playlist_text_view);
if (playlistId != null && itemPosition != -1) {
removeFromPlaylist.setVisibility(View.VISIBLE);
removeFromPlaylist.setOnClickListener(v -> {
songBottomSheetViewModel.removeFromPlaylist(playlistId, itemPosition, new com.cappielloantonio.tempo.repository.PlaylistRepository.AddToPlaylistCallback() {
@Override
public void onSuccess() {
Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_remove_success, Toast.LENGTH_SHORT).show();
dismissBottomSheet();
}
@Override
public void onFailure() {
Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_remove_failure, Toast.LENGTH_SHORT).show();
dismissBottomSheet();
}
@Override
public void onAllSkipped() {
dismissBottomSheet();
}
});
});
}
TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view);
addToPlaylist.setOnClickListener(v -> {

View File

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

View File

@@ -11,6 +11,7 @@ object Constants {
const val ARTIST_OBJECT = "ARTIST_OBJECT"
const val GENRE_OBJECT = "GENRE_OBJECT"
const val PLAYLIST_OBJECT = "PLAYLIST_OBJECT"
const val PLAYLIST_ID = "PLAYLIST_ID"
const val PODCAST_OBJECT = "PODCAST_OBJECT"
const val PODCAST_CHANNEL_OBJECT = "PODCAST_CHANNEL_OBJECT"
const val INTERNET_RADIO_STATION_OBJECT = "INTERNET_RADIO_STATION_OBJECT"

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import android.content.ContentResolver;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.util.Base64;
import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
@@ -25,6 +26,7 @@ import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.List;
import java.nio.charset.StandardCharsets;
@OptIn(markerClass = UnstableApi.class)
public class MappingUtil {
@@ -207,18 +209,34 @@ public class MappingUtil {
public static MediaItem mapInternetRadioStation(InternetRadioStation internetRadioStation) {
Uri uri = Uri.parse(internetRadioStation.getStreamUrl());
Uri artworkUri = null;
String homePageUrl = internetRadioStation.getHomePageUrl();
String coverArtId = null;
if (homePageUrl != null && !homePageUrl.isEmpty() && MusicUtil.isImageUrl(homePageUrl)) {
String encodedUrl = Base64.encodeToString(homePageUrl.getBytes(StandardCharsets.UTF_8),
Base64.URL_SAFE | Base64.NO_WRAP);
coverArtId = "ir_" + encodedUrl;
artworkUri = AlbumArtContentProvider.contentUri(coverArtId);
}
Bundle bundle = new Bundle();
bundle.putString("id", internetRadioStation.getId());
bundle.putString("title", internetRadioStation.getName());
bundle.putString("stationName", internetRadioStation.getName());
bundle.putString("uri", uri.toString());
bundle.putString("type", Constants.MEDIA_TYPE_RADIO);
bundle.putString("coverArtId", coverArtId);
if (homePageUrl != null) {
bundle.putString("homepageUrl", homePageUrl);
}
return new MediaItem.Builder()
.setMediaId(internetRadioStation.getId())
.setMediaMetadata(
new MediaMetadata.Builder()
.setTitle(internetRadioStation.getName())
.setArtworkUri(artworkUri)
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
@@ -288,13 +306,24 @@ public class MappingUtil {
}
private static Uri getUri(Child media) {
// Check if it's in our local SQL Database
DownloadRepository repo = new DownloadRepository();
Download localDownload = repo.getDownload(media.getId());
if (localDownload != null && localDownload.getDownloadUri() != null && !localDownload.getDownloadUri().isEmpty()) {
Log.d(TAG, "Playing local file for: " + media.getTitle());
return Uri.parse(localDownload.getDownloadUri());
}
// Legacy check for external directory, i think this was broken/buggy
if (Preferences.getDownloadDirectoryUri() != null) {
Uri local = ExternalAudioReader.getUri(media);
return local != null ? local : MusicUtil.getStreamUri(media.getId());
if (local != null) return local;
}
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(media.getId())
? getDownloadUri(media.getId())
: MusicUtil.getStreamUri(media.getId());
// Fallback to streaming
Log.d(TAG, "No local file found. Streaming: " + media.getTitle());
return MusicUtil.getStreamUri(media.getId());
}
private static Uri getUri(PodcastEpisode podcastEpisode) {
@@ -318,4 +347,4 @@ public class MappingUtil {
}
ExternalAudioReader.getRefreshEvents().observe(owner, event -> onRefresh.run());
}
}
}

View File

@@ -52,6 +52,10 @@ public class MusicUtil {
if (params.containsKey("c") && params.get("c") != null)
uri.append("&c=").append(params.get("c"));
String selectedBitrate = getBitratePreference();
String selectedFormat = getTranscodingFormatPreference();
Log.i(TAG, "DEBUG: Requesting Format: " + selectedFormat + " at Bitrate: " + selectedBitrate);
if (!Preferences.isServerPrioritized())
uri.append("&maxBitRate=").append(getBitratePreference());
if (!Preferences.isServerPrioritized())
@@ -73,7 +77,17 @@ public class MusicUtil {
}
public static Uri updateStreamUri(Uri uri) {
if (uri == null) return null;
String scheme = uri.getScheme();
// If it is local (content:// or file://), return it IMMEDIATELY.
// This prevents the code below from appending &maxBitRate to a local path.
if (scheme != null && (scheme.equals("content") || scheme.equals("file"))) {
return uri;
}
String s = uri.toString();
Matcher m1 = BITRATE_PATTERN.matcher(s);
s = m1.replaceAll("");
Matcher m2 = FORMAT_PATTERN.matcher(s);
@@ -157,7 +171,6 @@ public class MusicUtil {
return Uri.parse(uri.toString());
}
public static String getReadableDurationString(Long duration, boolean millis) {
long lenght = duration != null ? duration : 0;
@@ -303,13 +316,17 @@ public class MusicUtil {
if (network == null || networkCapabilities == null) return "raw";
String format;
if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
return Preferences.getAudioTranscodeFormatWifi();
format = Preferences.getAudioTranscodeFormatWifi();
Log.d(TAG, "DEBUG: Using WIFI Format: " + format);
} else if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
return Preferences.getAudioTranscodeFormatMobile();
format = Preferences.getAudioTranscodeFormatMobile();
Log.d(TAG, "DEBUG: Using MOBILE Format: " + format);
} else {
return Preferences.getAudioTranscodeFormatWifi();
format = Preferences.getAudioTranscodeFormatWifi();
}
return format;
}
public static String getBitratePreferenceForDownload() {
@@ -360,4 +377,15 @@ public class MusicUtil {
toFilter.addAll(filtered);
}
public static boolean isImageUrl(String url) {
if (url == null || url.isEmpty())
return false;
String path = url.toLowerCase().trim().split("\\?")[0];
return path.endsWith(".jpg") || path.endsWith(".jpeg") ||
path.endsWith(".png") || path.endsWith(".webp") ||
path.endsWith(".gif") || path.endsWith(".bmp") ||
path.endsWith(".svg");
}
}

View File

@@ -16,6 +16,7 @@ object Preferences {
private const val TOKEN = "token"
private const val SALT = "salt"
private const val LOW_SECURITY = "low_security"
private const val CLIENT_CERT = "client_cert"
private const val BATTERY_OPTIMIZATION = "battery_optimization"
private const val SERVER_ID = "server_id"
private const val OPEN_SUBSONIC = "open_subsonic"
@@ -24,12 +25,15 @@ object Preferences {
private const val IN_USE_SERVER_ADDRESS = "in_use_server_address"
private const val NEXT_SERVER_SWITCH = "next_server_switch"
private const val PLAYBACK_SPEED = "playback_speed"
private const val BITRATE_VISIBLE = "bitrate_visible"
private const val SKIP_SILENCE = "skip_silence"
private const val SHUFFLE_MODE = "shuffle_mode"
private const val REPEAT_MODE = "repeat_mode"
private const val IMAGE_CACHE_SIZE = "image_cache_size"
private const val STREAMING_CACHE_SIZE = "streaming_cache_size"
private const val LANDSCAPE_ITEMS_PER_ROW = "landscape_items_per_row"
private const val ENABLE_DRAWER_ON_PORTRAIT = "enable_drawer_on_portrait"
private const val HIDE_BOTTOM_NAVBAR_ON_PORTRAIT = "hide_bottom_navbar_on_portrait"
private const val IMAGE_SIZE = "image_size"
private const val MAX_BITRATE_WIFI = "max_bitrate_wifi"
private const val MAX_BITRATE_MOBILE = "max_bitrate_mobile"
@@ -88,8 +92,19 @@ object Preferences {
private const val ARTIST_DISPLAY_BIOGRAPHY= "artist_display_biography"
private const val NETWORK_PING_TIMEOUT = "network_ping_timeout_base"
@JvmStatic
private const val TILE_SIZE = "tile_size"
private const val AA_ALBUM_VIEW = "androidauto_album_view"
private const val AA_HOME_VIEW = "androidauto_home_view"
private const val AA_PLAYLIST_VIEW = "androidauto_playlist_view"
private const val AA_PODCAST_VIEW = "androidauto_podcast_view"
private const val AA_RADIO_VIEW = "androidauto_radio_view"
private const val AA_FIRST_TAB = "androidauto_first_tab"
private const val AA_SECOND_TAB = "androidauto_second_tab"
private const val AA_THIRD_TAB = "androidauto_third_tab"
private const val AA_FOURTH_TAB = "androidauto_fourth_tab"
private const val AA_SHUFFLE_GENRE_SONGS = "androidauto_shuffle_genre_songs"
@JvmStatic
fun getServer(): String? {
return App.getInstance().preferences.getString(SERVER, null)
}
@@ -162,6 +177,16 @@ object Preferences {
App.getInstance().preferences.edit().putBoolean(LOW_SECURITY, isLowSecurity).apply()
}
@JvmStatic
fun getClientCert(): String? {
return App.getInstance().preferences.getString(CLIENT_CERT, null)
}
@JvmStatic
fun setClientCert(clientCert: String?) {
App.getInstance().preferences.edit().putString(CLIENT_CERT, clientCert).apply()
}
@JvmStatic
fun getServerId(): String? {
return App.getInstance().preferences.getString(SERVER_ID, null)
@@ -270,6 +295,16 @@ object Preferences {
App.getInstance().preferences.edit().putFloat(PLAYBACK_SPEED, playbackSpeed).apply()
}
@JvmStatic
fun getBitrateVisible(): Boolean {
return App.getInstance().preferences.getBoolean(BITRATE_VISIBLE, true)
}
@JvmStatic
fun setBitrateVisible(bitrateVisible: Boolean) {
App.getInstance().preferences.edit().putBoolean(BITRATE_VISIBLE, bitrateVisible).apply()
}
@JvmStatic
fun isSkipSilenceMode(): Boolean {
return App.getInstance().preferences.getBoolean(SKIP_SILENCE, false)
@@ -310,6 +345,16 @@ object Preferences {
return App.getInstance().preferences.getString(LANDSCAPE_ITEMS_PER_ROW, "4")!!.toInt()
}
@JvmStatic
fun getEnableDrawerOnPortrait(): Boolean {
return App.getInstance().preferences.getBoolean(ENABLE_DRAWER_ON_PORTRAIT, false)
}
@JvmStatic
fun getHideBottomNavbarOnPortrait(): Boolean {
return App.getInstance().preferences.getBoolean(HIDE_BOTTOM_NAVBAR_ON_PORTRAIT, false)
}
@JvmStatic
fun getImageSize(): Int {
return App.getInstance().preferences.getString(IMAGE_SIZE, "-1")!!.toInt()
@@ -724,4 +769,64 @@ object Preferences {
fun setArtistDisplayBiography(displayBiographyEnabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(ARTIST_DISPLAY_BIOGRAPHY, displayBiographyEnabled).apply()
}
@JvmStatic
fun getTileSize(): Int {
val parsed = App.getInstance().preferences.getString(TILE_SIZE, "2")?.toIntOrNull()
return parsed?.takeIf { it in 2..6 } ?: 2
}
fun isAndroidAutoAlbumViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_ALBUM_VIEW, true)
}
@JvmStatic
fun isAndroidAutoHomeViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_HOME_VIEW, false)
}
@JvmStatic
fun isAndroidAutoPlaylistViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_PLAYLIST_VIEW, false)
}
@JvmStatic
fun isAndroidAutoPodcastViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_PODCAST_VIEW, false)
}
@JvmStatic
fun isAndroidAutoRadioViewEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_RADIO_VIEW, false)
}
@JvmStatic
fun getAndroidAutoFirstTab(): Int {
return App.getInstance().preferences.getString(AA_FIRST_TAB, "0")!!.toInt()
}
@JvmStatic
fun getAndroidAutoSecondTab(): Int {
return App.getInstance().preferences.getString(AA_SECOND_TAB, "1")!!.toInt()
}
@JvmStatic
fun getAndroidAutoThirdTab(): Int {
return App.getInstance().preferences.getString(AA_THIRD_TAB, "2")!!.toInt()
}
@JvmStatic
fun getAndroidAutoFourthTab(): Int {
return App.getInstance().preferences.getString(AA_FOURTH_TAB, "3")!!.toInt()
}
@JvmStatic
fun isAndroidAutoShuffleGenreSongsEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(AA_SHUFFLE_GENRE_SONGS, false)
}
@JvmStatic
fun setAndroidAutoShuffleGenreSongsEnabled(enabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(AA_SHUFFLE_GENRE_SONGS, enabled).apply()
}
}

View File

@@ -0,0 +1,174 @@
package com.cappielloantonio.tempo.util;
import android.content.Context;
import android.util.DisplayMetrics;
public class TileSizeManager {
private static TileSizeManager instance;
private int tileSizePx;
private int tileSpanCount;
private int tileSpacing;
private int genreSizePx;
private int genreSpanCount;
private int genreSpacing;
private int GenreSpacing;
private int discoverWidthPx;
private int discoverHeightPx;
private boolean tileIsInitialized;
private boolean genreIsInitialized;
private boolean discoverIsInitialized;
private TileSizeManager() {
}
public static TileSizeManager getInstance() {
if (instance == null) {
instance = new TileSizeManager();
}
return instance;
}
public int getTileSizePx(Context context) {
if( !tileIsInitialized )
calculateTileSize(context);
return tileSizePx;
}
public int getTileSpanCount(Context context) {
if( !tileIsInitialized )
calculateTileSize(context);
return tileSpanCount;
}
public int getTileSpacing(Context context) {
if( !tileIsInitialized )
calculateTileSize(context);
return tileSpacing;
}
public int getGenreSizePx(Context context) {
if( !genreIsInitialized )
calculateGenreSize(context);
return genreSizePx;
}
public int getGenreSpanCount(Context context) {
if( !genreIsInitialized )
calculateGenreSize(context);
return genreSpanCount;
}
public int getGenreSpacing(Context context) {
if( !genreIsInitialized )
calculateGenreSize(context);
return genreSpacing;
}
public int getDiscoverWidthPx(Context context) {
if( !discoverIsInitialized )
calculateTileSize(context);
return discoverWidthPx;
}
public int getDiscoverHeightPx(Context context) {
if( !discoverIsInitialized )
calculateTileSize(context);
return discoverHeightPx;
}
public void calculateTileSize(Context context) {
DisplayMetrics metrics = context.getResources().getDisplayMetrics();
float screenWidth = metrics.widthPixels;
float screenHeight = metrics.heightPixels;
// retrieve the divisor in the preferences
int userTileSize = Math.max(2, Math.min(6, Preferences.getTileSize()));
float divisor = (float)userTileSize;
// little pading = 10
tileSizePx = Math.round(Math.min(screenWidth, screenHeight) / divisor) - 10;
tileSpanCount = Math.max(2, Math.round(screenWidth / (float)tileSizePx) );
switch (userTileSize) {
default:
case 2: // XL
tileSpacing = 20;
break;
case 3: // L
tileSpacing = 15;
break;
case 4: // M
tileSpacing = 10;
break;
case 5: // S
tileSpacing = 6;
break;
case 6: // SX
tileSpacing = 2;
break;
}
tileIsInitialized = true;
}
public void calculateGenreSize(Context context) {
DisplayMetrics metrics = context.getResources().getDisplayMetrics();
float screenWidth = metrics.widthPixels;
float screenHeight = metrics.heightPixels;
// retrieve the divisor in the preferences
int userTileSize = Math.max(2, Math.min(3, Preferences.getTileSize()));
float divisor = (float)userTileSize;
// little pading = 10
genreSizePx = Math.round(Math.min(screenWidth, screenHeight) / divisor) - 10;
genreSpanCount = Math.max(2, Math.round(screenWidth / (float)genreSizePx) );
switch (userTileSize) {
default:
case 2: // XL
genreSpacing = 20;
break;
case 3: // L
genreSpacing = 15;
break;
case 4: // M
genreSpacing = 10;
break;
case 5: // S
genreSpacing = 6;
break;
case 6: // XS
genreSpacing = 2;
break;
}
genreIsInitialized = true;
}
public void calculateDiscoverSize(Context context) {
DisplayMetrics metrics = context.getResources().getDisplayMetrics();
float screenWidth = metrics.widthPixels;
float screenHeight = metrics.heightPixels;
float discoverDivisor;
// retrieve the divisor in the preferences
int userTileSize = Math.max(2, Math.min(6, Preferences.getTileSize()));
switch (userTileSize) {
default:
case 2: // XL
discoverDivisor = 1.0f;
break;
case 3: // L
discoverDivisor = 1.25f;
break;
case 4: // M
discoverDivisor = 1.5f;
break;
case 5: // S
discoverDivisor = 1.75f;
break;
case 6: // XS
discoverDivisor = 2.0f;
break;
}
discoverWidthPx = Math.round(Math.min(screenWidth, screenHeight) / discoverDivisor) - 50;
discoverHeightPx = Math.round((float)discoverWidthPx * 0.6f);
discoverIsInitialized = true;
}
}

View File

@@ -33,12 +33,18 @@ class TranscodingMediaSource(
init {
val extras = mediaItem.mediaMetadata.extras
if (extras != null && extras.containsKey("duration")) {
val uri = mediaItem.localConfiguration?.uri
val isLocal = uri?.scheme == "content" || uri?.scheme == "file"
// Only apply the override if it's NOT a local file
if (!isLocal && extras != null && extras.containsKey("duration")) {
val seconds = extras.getInt("duration")
if (seconds > 0) {
durationUs = Util.msToUs(seconds * 1000L)
}
}
currentSource = progressiveMediaSourceFactory.createMediaSource(mediaItem)
}
override fun getMediaItem() = mediaItem

View File

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

View File

@@ -5,11 +5,13 @@ import android.app.Application;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.model.RecentSearch;
import com.cappielloantonio.tempo.repository.SearchingRepository;
import com.cappielloantonio.tempo.subsonic.models.SearchResult2;
import com.cappielloantonio.tempo.subsonic.models.SearchResult3;
import com.cappielloantonio.tempo.ui.fragment.SearchFragment;
import java.util.ArrayList;
import java.util.List;
@@ -43,8 +45,9 @@ public class SearchViewModel extends AndroidViewModel {
return searchingRepository.search2(title);
}
public LiveData<SearchResult3> search3(String title) {
return searchingRepository.search3(title);
@UnstableApi
public LiveData<SearchResult3> search3(SearchFragment sf, String title) {
return searchingRepository.search3(sf, title);
}
public void insertNewSearch(String search) {

View File

@@ -16,6 +16,7 @@ import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.FavoriteRepository;
import com.cappielloantonio.tempo.repository.PlaylistRepository;
import com.cappielloantonio.tempo.repository.SharingRepository;
import com.cappielloantonio.tempo.repository.SongRepository;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
@@ -39,6 +40,7 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
private final ArtistRepository artistRepository;
private final FavoriteRepository favoriteRepository;
private final SharingRepository sharingRepository;
private final PlaylistRepository playlistRepository;
private Child song;
@@ -52,6 +54,7 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
artistRepository = new ArtistRepository();
favoriteRepository = new FavoriteRepository();
sharingRepository = new SharingRepository();
playlistRepository = new PlaylistRepository();
}
public Child getSong() {
@@ -62,6 +65,10 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
this.song = song;
}
public void removeFromPlaylist(String playlistId, int index, PlaylistRepository.AddToPlaylistCallback callback) {
playlistRepository.removeSongFromPlaylist(playlistId, index, callback);
}
public void setFavorite(Context context) {
if (song.getStarred() != null) {
if (NetworkUtil.isOffline()) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:viewportHeight="960"
android:viewportWidth="960"
android:width="24dp">
<path
android:fillColor="@android:color/white"
android:pathData="M480,660Q555,660 607.5,607.5Q660,555 660,480Q660,405 607.5,352.5Q555,300 480,300Q405,300 352.5,352.5Q300,405 300,480Q300,555 352.5,607.5Q405,660 480,660ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Z"/>
</vector>

View File

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

View File

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

View File

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

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