189 Commits

Author SHA1 Message Date
eddyizm
ff0c42d14c fix: added preference that got lost in merging, removed old media factory ref, bumped version for release 2025-10-07 21:51:02 -07:00
eddyizm
d1e247f9e2 Merge branch 'feature-widget-playing' into development 2025-10-07 21:31:44 -07:00
eddyizm
f1d19142fa feat: Add home screen music playback widget and some updates in Turkish localization (#98) 2025-10-07 21:28:10 -07:00
eddyizm
45793c343a chore: formatting 2025-10-07 21:26:36 -07:00
eddyizm
aa4249842d Sin tan1729 skip duplicates (#149) 2025-10-06 22:25:30 -07:00
eddyizm
126663f1e5 Merge branch 'development' into SinTan1729-skip-duplicates 2025-10-06 22:25:17 -07:00
eddyizm
ec19e8c401 feat: Added support for skipping duplicates (#135) 2025-10-06 22:23:06 -07:00
eddyizm
717f95a04a Merge branch 'development' into skip-duplicates 2025-10-06 22:22:52 -07:00
eddyizm
ccce01a61b Merge branch 'playlist-duplicates' into SinTan1729-skip-duplicates 2025-10-06 21:49:05 -07:00
eddyizm
a7682d7656 fix: updated settings stitles, merge conflict 2025-10-06 21:43:30 -07:00
eddyizm
84de93a4f1 Merge branch 'development' into playlist-duplicates 2025-10-06 21:27:29 -07:00
eddyizm
78c4c89eca feat: Support user-defined download directory for media (#21) 2025-10-06 21:16:17 -07:00
SinTan1729
328beaff90 fix: Use string for settings section name 2025-10-06 22:04:23 -05:00
SinTan1729
9d5d89d648 new: Separate toast for when all songs were skipped
Also, fixed grammatical issue in toast where they were all singular.
2025-10-06 21:58:41 -05:00
SinTan1729
cd8b06f544 fix: Reverted old toast while adding to playlist 2025-10-06 21:50:00 -05:00
SinTan1729
47a0def06c fix: Removed unnecessary code 2025-10-06 21:48:42 -05:00
SinTan1729
1c2f1aa061 chg: Move the playlist duplicates option to preferences
As per the recommendation of @eddyizm
2025-10-06 21:47:57 -05:00
eddyizm
30281e8f2d fix: had the heart icons inverted 2025-10-06 15:32:54 -07:00
eddyizm
3c58e6fbb2 chore: added sha256 signing key for verification (#147) 2025-10-05 13:33:06 -07:00
eddyizm
99a399b4d7 chore: added sha256 signing key for verification 2025-10-05 13:31:46 -07:00
eddyizm
1da0a0b810 Unify and update polish translation (#146) 2025-10-05 13:03:17 -07:00
eddyizm
539920965e Notification heart rating (#140) 2025-10-05 13:01:44 -07:00
eddyizm
9a64eeabe6 feat: added preference to disable heart and show shuffle instead 2025-10-05 12:59:24 -07:00
eddyizm
791190f681 Merge branch 'development' into notification-heart-rating 2025-10-05 08:52:17 -07:00
skajmer
c03fca8039 Unify and update translation
Unified so the strings are in the same places as they are in the english version which makes editing stuff easier. Updated existing strings, and added some that were missing.
2025-10-05 13:38:29 +02:00
le-firehawk
620fba0a14 fix: Prevent externalAudioReader from hogging the main thread 2025-10-04 23:33:48 +09:30
le-firehawk
1357c5c062 feat: Integrate external downloads into downloaded songs view 2025-10-04 23:33:48 +09:30
le-firehawk
682f63ef38 feat: Add metadata caching and proper integration for external media files 2025-10-04 23:33:48 +09:30
le-firehawk
24864637f9 feat: Hook external audio write into file cache from external audio reader, fix download notifications 2025-10-04 23:33:48 +09:30
le-firehawk
3ba19be4d9 feat: Load media downloaded as file for offline use 2025-10-04 23:33:48 +09:30
le-firehawk
cce6456951 feat: Support user-defined download directory for media 2025-10-04 23:33:48 +09:30
eddyizm
fda586c4d8 chore: add link to discussion page in settings (#143) 2025-10-02 21:33:25 -07:00
eddyizm
57be72d5d4 chore: add link to discussion page in settings 2025-10-02 21:32:34 -07:00
eddyizm
c2354d4d42 fix: Lag during startup when local url is not available (#110) 2025-10-02 07:10:32 -07:00
eddyizm
a940af934c feat: notification-heart-rating 2025-10-01 22:27:26 -07:00
eddyizm
5891ec800c chore: groundwork for heart rating 2025-09-30 15:41:58 -07:00
eddyizm
7259a82b67 feat: Enable downloading of song lyrics for offline viewing (#99) 2025-09-29 07:02:14 -07:00
le-firehawk
c2b6d7eed5 feat: Enable downloading of song lyrics for offline viewing 2025-09-29 22:04:38 +09:30
eddyizm
8bb6c02e46 feat: download starred artists. (#137) 2025-09-28 16:17:37 -07:00
eddyizm
47380a79a5 fix: added init on home tab and dialog, refactor and check for songs for albums/artists before displaying dialog 2025-09-28 16:14:42 -07:00
eddyizm
a187ba1e75 fix: moved api call back to artist repository after losing the thread. 2025-09-27 22:37:30 -07:00
eddyizm
3eb9b2fb5c chore: added dialog to starred artists sync 2025-09-27 21:52:04 -07:00
eddyizm
a547e19361 Merge branch 'development' into Sync-starred-artists-offline 2025-09-27 18:25:20 -07:00
eddyizm
f4722fa0a8 fix: Update search query validation to require at least 2 characters instead of 3 (#124) 2025-09-27 18:17:23 -07:00
eddyizm
ee738bc4c7 feat: download starred artists. 2025-09-27 15:37:59 -07:00
SinTan1729
a22883fdde fix: The layout should be more in line with the playlist entries 2025-09-26 22:51:52 -05:00
SinTan1729
2acf11023a fix: Crash when trying to add to an empty playlist 2025-09-26 19:19:23 -05:00
SinTan1729
9736890e3c fix: Show proper number in add to playlist dialog toast 2025-09-26 16:48:56 -05:00
SinTan1729
e790bf3eb6 chg: Comment out unused code 2025-09-26 16:39:46 -05:00
SinTan1729
e1d63a9eef feat: Support skipping duplicates 2025-09-26 16:24:21 -05:00
SinTan1729
134a1605ad fix: Get rid of the try-catch since it's considered bad practice in Java
This matches the treatment done at other places in the code, so it
should be fine.
2025-09-26 05:56:48 -05:00
eddyizm
1b45036963 fix: removed universalApk ref from build 2025-09-24 22:05:34 -07:00
eddyizm
0ba12c3d84 Update French localization (#125) 2025-09-24 09:25:40 -07:00
Benoît Smith
8cc3356b14 Update strings.xml 2025-09-24 11:38:40 +02:00
Jaime García
9d439b726b fix: Update search query validation to require at least 2 characters instead of 3 2025-09-24 04:03:51 +02:00
eddyizm
d4c0e30fd1 fix: Prevent crash when getting artist radio and song list is null (#117) 2025-09-23 17:34:24 -07:00
eddyizm
bb23d7e866 chore: updated changelog with latest updates 2025-09-23 15:34:07 -07:00
eddyizm
7321ef46f2 v3.15.0 (#118) 2025-09-23 15:23:41 -07:00
eddyizm
2fe2c2b28b chore: version bump 2025-09-23 15:21:43 -07:00
Jaime García
a9318ec5d0 fix: Prevent crash when getting artist radio and song list is null 2025-09-23 23:45:00 +02:00
eddyizm
eb29dc2fb2 feat: Tap anywhere on the song item to toggle playback (#112) 2025-09-23 12:17:56 -07:00
eddyizm
287e4a2b10 fix: add listener to track playlist click/change (#113) 2025-09-23 12:16:22 -07:00
eddyizm
b7d56c2d70 fix: null check for scrobble when disconnecting from chromecast, which was crashing app 2025-09-23 12:03:04 -07:00
eddyizm
5a6d101bdf fix: playlist selection working now 2025-09-23 09:34:59 -07:00
Jaime García
969f0b5b21 feat: Replace play/pause button with an icon, allow tapping on full item to play/pause song in song lists 2025-09-23 17:55:18 +02:00
Jaime García
14939d20fd feat: Replace play/pause button with an icon, allow tapping on full item to play/pause song in Queue 2025-09-23 17:55:17 +02:00
mucahit-kaya
35af1f9038 fix(widget): refine layouts and progress UX across sizes
Compact (4×1)
- Reduce root vertical padding so the 4×1 cell yields ~56dp of content height.
- Make album art a true square (50×50dp) and center vertically; keeps edges
  clear of rounded corners.
- Tighten timing block: 2dp progress bar; 10sp labels with no extra font
  padding; prevents elapsed/total text from slipping below the background.
- Wrap album art in a 50×50dp FrameLayout with a new 6dp-radius background
  drawable; soft corners while remaining visually smaller than the widget body.
- Mirror the same structure in the preview layout so Studio preview matches
  on-device rendering.
  (app/src/main/res/layout/widget_layout_compact.xml,
   app/src/main/res/drawable/widget_album_art_bg.xml)

Large Short (4×2)
- Wrap album art in a fixed 90dp square container and enforce a true square
  crop via centerCrop.
- Tighten vertical spacing: thinner progress bar, closer timing row, controls
  shifted down for better balance.
- Keep album/timing text to the left of the controls but retune spacing so the
  stack stays fully inside the widget bounds.

Large (4×3 and up)
- Restructure to a vertical stack: header row (album art + text), full-width
  progress bar, timing row, primary controls, then secondary controls.
- Lock album art to a 150dp square; progress bar spans the widget beneath the
  header to match the new visual hierarchy.

Based-on: cd28ee0764
Co-authored-by: The Firehawk <firehawk@opayq.net>
Co-Authored-By: Mücahit Kaya <kaya-mucahit@outlook.com>
Co-Authored-By: Firehawk <firehawk@opayq.net>
2025-09-23 14:32:01 +02:00
mucahit-kaya
b79cfa4af0 fix(widget): resume progress updates during playback
The widget was only updating on play/pause state changes, so the timer
did not advance while playback continued. Added a Handler loop in
MediaService that updates the widget every second while playback is
running and clears it when playback stops, ensuring the progress bar
refreshes regularly.

Co-Authored-By: Firehawk <firehawk@opayq.net>
2025-09-23 14:32:00 +02:00
le-firehawk
e81e1a5356 fix: Include song position and duration in widget
Co-authored-by: Mücahit Kaya <kaya-mucahit@outlook.com>
Co-authored-by: The Firehawk <firehawk@opayq.net>
2025-09-23 14:29:21 +02:00
mucahit-kaya
cc0e264a17 feat: Add home screen music playback widget
Introduces a new app widget for music playback control and display. Adds widget provider classes, update manager, view factory, and related resources (layouts, colors, strings, XML). Integrates widget updates with MediaService to reflect current playback state. Updates AndroidManifest to register the widget.
2025-09-23 14:29:21 +02:00
SinTan1729
a83495f353 fix: Removed unnecessary imports 2025-09-23 02:30:22 -05:00
SinTan1729
be4346b3d1 fix: Lag during startup when local url is not available 2025-09-23 02:26:26 -05:00
eddyizm
2e29e9537a feat: Mark currently playing song with play/pause button (#107) 2025-09-22 12:40:33 -07:00
eddyizm
bc0adfe8e0 feat: added 32bit build and debug build for testing. Removed unused f… (#108) 2025-09-22 12:38:32 -07:00
eddyizm
5261ca317b feat: added 32bit build and debug build for testing. Removed unused function after merging changes for media service 2025-09-22 12:32:33 -07:00
eddyizm
bf4ff3f1f9 Updates to polish translation (#105) 2025-09-22 12:15:55 -07:00
Jaime García
cd195dbba0 refactor: Remove unused import 2025-09-22 20:21:27 +02:00
Jaime García
7ec78991a5 refactor: Rename methods and variables 2025-09-22 20:10:57 +02:00
Jaime García
e1c5a60805 refactor: Rename methods and variables 2025-09-22 20:03:02 +02:00
Jaime García
f74813ef69 refactor: Remove commented code 2025-09-22 19:47:07 +02:00
Jaime García
040558198e refactor: Add some code mistakenly removed, remove some comments, remove unused parameter 2025-09-22 19:39:53 +02:00
Jaime García
5ab68e4a98 feat: Add play/pause button in song lists 2025-09-22 19:28:01 +02:00
skajmer
aa8fac43a6 Update strings.xml
New strings (mainly the EQ ones)
2025-09-22 17:37:37 +02:00
Jaime García
905bb3e3c5 fix: Use proper play icon 2025-09-22 01:31:34 +02:00
Jaime García
d810010090 feat: Mark currently playing song in PlayerSongQueueAdapter 2025-09-22 00:35:23 +02:00
Jaime García
52ba783a90 feat: Mark currently playing song in SongHorizontalAdapter 2025-09-22 00:15:52 +02:00
eddyizm
d72855e160 fix: Resolve playback issues with live radio MPEG & HLS streams (#89) 2025-09-21 11:59:28 -07:00
Jaime García
82a9f00173 Merge branch 'development' into fix-live-radio-streams 2025-09-21 19:27:21 +02:00
eddyizm
3f5749f7e1 feat: Built-in audio equalizer (#94) 2025-09-21 10:20:49 -07:00
eddyizm
cd2ab36351 16 bug it only plays the first song on an album (#81) 2025-09-21 09:56:26 -07:00
eddyizm
a6688f897a chore: removed comments 2025-09-21 09:30:41 -07:00
eddyizm
64658dda1f Update Korean translations (#97) 2025-09-21 06:13:24 -07:00
WooJin Kong
d9f701d9d3 feat: Update korean translations 2025-09-14 01:55:24 +09:00
Jaime García
60fee3c77c fix: Allow only integer values in equalizer seek bars, show positive dB values with plus symbol 2025-09-09 02:42:52 +02:00
Jaime García
b89086c5be fix: Prevent switch enable animation when opening equalizer fragment 2025-09-09 02:11:36 +02:00
Jaime García
d3dd236054 feat: Hide Equalizer button in player when it is not available 2025-09-09 01:46:06 +02:00
Jaime García
2e3330b63f feat: Hide Equalizer option when it is not available 2025-09-09 01:29:47 +02:00
Jaime García
e604c9ba86 chore(i18n): Update equalizer option summary string in Spanish 2025-09-08 22:47:29 +02:00
Jaime García
2bf39d846e style: Convert margin values to density-independent pixels in EqualizerFragment 2025-09-08 22:06:26 +02:00
Jaime García
06066f1f66 refactor: Remove redundant null checks after loading equalizer band levels 2025-09-08 20:08:05 +02:00
Jaime García
7c0d44680f feat: Add audio equalizer with UI 2025-09-08 19:28:34 +02:00
Jaime García
d4cb6c5c9a fix: Update MediaService in all build variants 2025-09-06 00:20:35 +02:00
Jaime García
fab18c130e fix: Support HLS (.m3u8) streams with parameters in URL 2025-09-05 21:29:11 +02:00
Jaime García
bd753f4489 fix: Use defined media type for live radio detection, relocate DynamicMediaSourceFactory 2025-09-05 11:19:47 +02:00
Jaime García
e43a2b6fe5 fix: Resolve playback issues with live radio MPEG & HLS streams 2025-09-05 04:46:01 +02:00
eddyizm
c62d2ace4d Update RU locale (#87) 2025-09-03 20:00:57 -07:00
Denis Bezykornov
6d403f808c Update RU locale 2025-09-03 19:28:28 +03:00
eddyizm
1223062388 Create FUNDING.yml 2025-09-02 07:57:51 -07:00
eddyizm
b0ddd5388b Update French localization (#84) 2025-09-02 07:11:05 -07:00
Benoît Smith
92f79a8e3d Update strings.xml (FR) [2] 2025-09-02 10:49:16 +02:00
Benoît Smith
473d7e4e9c Update strings.xml (FR) 2025-09-02 10:48:04 +02:00
eddyizm
7ca0415274 chore: bumped version 2025-08-31 20:25:32 -07:00
eddyizm
cf7feacdc0 fix: casting full album/playlist working as intended, passing metadata for album artwork in small square #16 2025-08-31 20:24:48 -07:00
eddyizm
4740028a44 chore: updated version for release 2025-08-31 13:23:33 -07:00
eddyizm
fe2c163aaa bug fixes, chores, docs v3.14.8 (#80) 2025-08-31 12:43:06 -07:00
eddyizm
c6f08d9cec chore: started usage docs, added a couple of discussion items, created layout 2025-08-31 12:38:13 -07:00
eddyizm
59b40df9ef fix: Disable "sync starred tracks/albums" switches when Cancel is clicked in warning dialog, use proper view for "Sync starred albums" dialog (#79) 2025-08-31 12:36:35 -07:00
eddyizm
81726baa08 style: Center subtitle text in empty_download_layout in fragment_download.xml when there is more than one line (#78) 2025-08-31 12:36:15 -07:00
eddyizm
af92a7b11a chore(i18n): Update Spanish (es-ES) and English translations (#77) 2025-08-31 12:35:40 -07:00
Jaime García
0578745bee fix: use "DialogStarredAlbumSync" view binding instead of "DialogStarredSync" 2025-08-31 17:48:56 +02:00
Jaime García
e24063e460 fix: disable sync starred tracks/albums switches in when Cancel is clicked in warning dialog 2025-08-31 17:39:35 +02:00
Jaime García
4f1b1b603e style: center subtitle text in empty_download_layout in fragment_download.xml when there is more than one line 2025-08-31 17:05:27 +02:00
Jaime García
7279c62944 chore(i18n): update Spanish translation 2025-08-31 16:44:00 +02:00
eddyizm
a59d46f884 Merge branch 'development' of github.com:eddyizm/tempo into development 2025-08-31 07:39:22 -07:00
eddyizm
10285b308d fix: Use correct SearchView widget to avoid crash in AlbumListPageFragment (#76) 2025-08-31 07:38:04 -07:00
Jaime García
8e2c5d1fee chore(i18n): update English translation 2025-08-31 16:32:01 +02:00
Jaime García
f59a360eb7 fix: Use correct SearchView widget to avoid crash in AlbumListPageFragment 2025-08-31 15:32:56 +02:00
eddyizm
accf5fddc2 chore: changelog update 2025-08-30 11:36:42 -07:00
eddyizm
cc61d1cd48 v3.14.1 release (#74) 2025-08-30 11:00:57 -07:00
eddyizm
6a16159cf0 fix: forgot sync album dialog, bump version for release 2025-08-30 10:58:38 -07:00
eddyizm
eaf2710054 fix: minor refactor of sync album observer 2025-08-30 09:23:44 -07:00
eddyizm
31d91f7215 feat: adds sync starred albums functionality #66 (#73) 2025-08-30 09:06:47 -07:00
eddyizm
f854f49686 feat: adds sync starred albums functionality #66 2025-08-30 09:04:25 -07:00
eddyizm
9e8870a86a Update French localization (#70) 2025-08-29 14:11:23 -07:00
Benoît Smith
a0040c52a0 More updates for strings.xml (FR) 2025-08-28 11:59:57 +02:00
Benoît Smith
65f6347faf Merge branch 'eddyizm:development' into development 2025-08-28 11:18:12 +02:00
Benoît Smith
85fa2f768e Update strings.xml (FR) 2025-08-28 11:17:32 +02:00
eddyizm
cc5abd150a fix: artist filtering in library view browse artist resolves #45 (#69) 2025-08-27 18:32:46 -07:00
eddyizm
1ed6ac6cff fix: artist filtering in library view browse artist resolves #45 2025-08-27 18:29:14 -07:00
eddyizm
cc6cb077b4 fix: catches null value and prepares bundle appropriately adding sing… (#64) 2025-08-26 21:59:33 -07:00
eddyizm
4be0acf76c fix: catches null value and prepares bundle appropriately adding single track to expected array list. closes #58 2025-08-26 21:57:20 -07:00
eddyizm
7d843390db setting-to-hide-song-rating (#60) 2025-08-25 20:49:30 -07:00
eddyizm
5c5316055c feat: setting to show/hide 5 star rating on playerview (#59)
First pass, I was not able to get the setting to update without having to restart the app. My attempt at using live data was missing something so I will have to revisit this when I get a better hang of it.
2025-08-25 20:42:09 -07:00
eddyizm
f1a179e7f8 Merge branch 'development' into setting-to-hide-song-rating 2025-08-24 19:14:36 -07:00
eddyizm
0377c5e939 feat: setting to show/hide 5 star rating on playerview 2025-08-24 18:55:36 -07:00
eddyizm
614ce8b466 style: Add song rating bar in landscape player controller layout (#57) 2025-08-24 16:33:50 -07:00
eddyizm
9e87b53bc9 feat: rating dialog added to album page (#52) 2025-08-24 16:32:50 -07:00
Jaime García
08023026b4 style: Add song rating bar in landscape player controller layout 2025-08-24 19:24:05 +02:00
eddyizm
1bbcf6c790 feat: rating dialog added to album page 2025-08-23 17:57:45 -07:00
eddyizm
698ca3b22b chore: changelog update 2025-08-23 14:05:01 -07:00
eddyizm
b2d875ac98 Merge pull request #51 from eddyizm/development
Development
2025-08-23 13:52:44 -07:00
eddyizm
02eef97171 chore: bumping version for release 2025-08-23 13:48:00 -07:00
eddyizm
26a5fb029a fix: moved hardcoded italian save text to string template, updated with english and italian language xmls 2025-08-23 13:04:17 -07:00
eddyizm
c38c7c3deb Merge pull request #50 from mucahit-kaya/development
feat: Add Turkish localization (values-tr)
2025-08-22 22:16:06 -07:00
eddyizm
8ed0a4642b chore: adding a note/not fully baked label to the sync user play queue setting #47 2025-08-22 22:12:22 -07:00
mucahit-kaya
6cfa04d368 Add Turkish language 2025-08-22 17:08:08 +02:00
eddyizm
af98f8d1a9 Merge pull request #44 from jaime-grj/rating-position
style: Change position and size of rating container
2025-08-18 15:17:53 -07:00
Jaime García
469204daac style: Change position and size of rating container 2025-08-16 02:43:02 +02:00
eddyizm
8943faf44c chore: updated change log 2025-08-15 10:42:03 -07:00
eddyizm
fea6366d84 Merge pull request #42 from eddyizm/development
Development - v3.12.0 release set up
2025-08-15 10:08:31 -07:00
eddyizm
92ac2e5684 chore: updated readme and bumped version for release. 2025-08-15 09:29:45 -07:00
eddyizm
06a52afa18 Merge pull request #40 from eddyizm/18-show-rating-on-song-view
feat: show rating on song view
2025-08-14 22:08:35 -07:00
eddyizm
87f6db9e79 chore: cleaned up dev log imports, commented code 2025-08-14 21:54:25 -07:00
eddyizm
5fa46cc49b feat:show rating on song view and allow setting/updating. #17 fixed a fr string error 2025-08-14 21:46:33 -07:00
eddyizm
4da967910a Merge pull request #39 from benoit-smith/development
Update French localization
2025-08-14 12:46:11 -07:00
Benoît Smith
3b18f39948 Update French localization 2025-08-14 10:56:28 +02:00
eddyizm
c9e0581815 Merge branch 'development' into 18-show-rating-on-song-view 2025-08-12 21:25:51 -07:00
eddyizm
b0fcc31f7b Merge pull request #38 from jaime-grj/fix-playercodecbitrateinfo
feat: added transcoding codec and bitrate info to PlayerControllerFragment, replace hardcoded strings
2025-08-12 21:24:26 -07:00
eddyizm
e98c9483c8 Merge pull request #37 from jaime-grj/fix-trackinfodialog
fix: Show placeholder string in TrackInfoDialog fields when there is no data
2025-08-12 21:23:45 -07:00
Jaime García
98a45b6059 fix: added transcoding codec and bitrate info to PlayerControllerFragment, replace hardcoded strings with dynamic values 2025-08-11 22:26:30 +02:00
Jaime García
6e070dfef0 fix: Avoid showing radio stream URL in Artist field of TrackInfoDialog 2025-08-11 20:58:06 +02:00
eddyizm
910cce90f5 Merge pull request #36 from benoit-smith/development
Update French localization
2025-08-11 07:11:15 -07:00
Jaime García
1a70ccd8f4 fix: Show placeholder string in TrackInfoDialog fields when there is no size and duration 2025-08-11 14:29:19 +02:00
Benoît Smith
7830657fe1 Update strings.xml French localization (more) 2025-08-11 10:56:06 +02:00
Benoît Smith
0351ccfc95 Update strings.xml French localization 2025-08-11 10:46:19 +02:00
Benoît Smith
10af6fb4ce Update arrays.xml French localization 2025-08-11 09:41:50 +02:00
Jaime García
61ec15e696 fix: Show placeholder string in TrackInfoDialog fields when there is no year, track number, bitrate and/or disc number 2025-08-11 03:02:01 +02:00
eddyizm
d21bd475a1 wip: initial new rating on song layout 2025-08-10 12:54:05 -07:00
eddyizm
7e34f6ee64 Merge branch 'development' of github.com:eddyizm/tempo into development 2025-08-10 07:50:05 -07:00
eddyizm
7cfefe76cc fix: removed duplicated screenshots from readme. 2025-08-10 07:49:28 -07:00
eddyizm
4585533740 Merge pull request #30 from skajmer/tempofork
Translations for sections
2025-08-10 07:40:43 -07:00
skajmer
07c1760c39 Additional strings
from: eddyizm/tempo/issues/29
2025-08-10 10:26:22 +02:00
eddyizm
24d4e67872 Merge pull request #33 from jaime-grj/fix-offlinemodetext
style: increased "Offline mode" text size, changed its color in dark theme
2025-08-09 22:48:21 -07:00
eddyizm
bec840620c Merge pull request #31 from BreadWare92/feat/i18n-german-track-info-home-section
feat(i18n): add German translations for track info and home section strings (#29)
2025-08-09 22:44:08 -07:00
Jaime García
2fa4ddf874 style: increased "Offline mode" text size, changed its color in dark theme 2025-08-10 02:10:20 +02:00
Matthias Reihs
e16f88cb73 feat(i18n): add German translations for track info and home section strings (#29) 2025-08-09 23:43:11 +02:00
skajmer
f79b05cb67 Translations for sections 2025-08-09 23:13:23 +02:00
eddyizm
ed7c572578 chore: updated ignore for release files 2025-08-09 11:14:56 -07:00
eddyizm
e891214831 Merge pull request #28 from eddyizm/development
v3.11.2 - merging to main
2025-08-09 11:03:15 -07:00
131 changed files with 9786 additions and 526 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: eddyizm
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

5
.gitignore vendored
View File

@@ -14,4 +14,7 @@
.cxx
/.idea/
.env
.vscode/settings.json
.vscode/settings.json
# release / debug files
tempus-release-key.jks
app/tempo/

View File

@@ -2,6 +2,84 @@
***This log is for this fork to detail updates since 3.9.0 from the main repo.***
## [3.15.0](https://github.com/eddyizm/tempo/releases/tag/v3.15.0) (2025-09-23)
## What's Changed
* chore: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/84
* chore: Update RU locale by @ArchiDevil in https://github.com/eddyizm/tempo/pull/87
* chore: Update Korean translations by @kongwoojin in https://github.com/eddyizm/tempo/pull/97
* fix: only plays the first song on an album by @eddyizm in https://github.com/eddyizm/tempo/pull/81
* fix: handle null and not crash when disconnecting chromecast by @eddyizm in https://github.com/eddyizm/tempo/pull/81
* feat: Built-in audio equalizer by @jaime-grj in https://github.com/eddyizm/tempo/pull/94
* fix: Resolve playback issues with live radio MPEG & HLS streams by @jaime-grj in https://github.com/eddyizm/tempo/pull/89
* chore: Updates to polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/105
* feat: added 32bit build and debug build for testing. Removed unused f… by @eddyizm in https://github.com/eddyizm/tempo/pull/108
* feat: Mark currently playing song with play/pause button by @jaime-grj in https://github.com/eddyizm/tempo/pull/107
* fix: add listener to track playlist click/change by @eddyizm in https://github.com/eddyizm/tempo/pull/113
* feat: Tap anywhere on the song item to toggle playback by @jaime-grj in https://github.com/eddyizm/tempo/pull/112
## New Contributors
* @ArchiDevil made their first contribution in https://github.com/eddyizm/tempo/pull/87
* @kongwoojin made their first contribution in https://github.com/eddyizm/tempo/pull/97
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.14.8...v3.15.0
## [3.14.8](https://github.com/eddyizm/tempo/releases/tag/v3.14.8) (2025-08-30)
## What's Changed
* fix: Use correct SearchView widget to avoid crash in AlbumListPageFragment by @jaime-grj in https://github.com/eddyizm/tempo/pull/76
* chore(i18n): Update Spanish (es-ES) and English translations by @jaime-grj in https://github.com/eddyizm/tempo/pull/77
* style: Center subtitle text in empty_download_layout in fragment_download.xml when there is more than one line by @jaime-grj in https://github.com/eddyizm/tempo/pull/78
* fix: Disable "sync starred tracks/albums" switches when Cancel is clicked in warning dialog, use proper view for "Sync starred albums" dialog by @jaime-grj in https://github.com/eddyizm/tempo/pull/79
* bug fixes, chores, docs v3.14.8 by @eddyizm in https://github.com/eddyizm/tempo/pull/80
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.14.1...v3.14.8
## [3.14.1](https://github.com/eddyizm/tempo/releases/tag/v3.14.1) (2025-08-30)
## What's Changed
* feat: rating dialog added to album page by @eddyizm in https://github.com/eddyizm/tempo/pull/52
* style: Add song rating bar in landscape player controller layout by @jaime-grj in https://github.com/eddyizm/tempo/pull/57
* feat: setting to show/hide 5 star rating on playerview by @eddyizm in https://github.com/eddyizm/tempo/pull/59
* chore: setting-to-hide-song-rating by @eddyizm in https://github.com/eddyizm/tempo/pull/60
* fix: catches null value and prepares bundle appropriately adding sing… by @eddyizm in https://github.com/eddyizm/tempo/pull/64
* fix: artist filtering in library view browse artist resolves #45 by @eddyizm in https://github.com/eddyizm/tempo/pull/69
* chore: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/70
* feat: adds sync starred albums functionality #66 by @eddyizm in https://github.com/eddyizm/tempo/pull/73
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.13.0...v3.14.1
## [3.13.0](https://github.com/eddyizm/tempo/releases/tag/v3.13.0) (2025-08-23)
## What's Changed
* style: Change position and size of rating container by @jaime-grj in https://github.com/eddyizm/tempo/pull/44
* feat: Add Turkish localization (values-tr) by @mucahit-kaya in https://github.com/eddyizm/tempo/pull/50
* chore: adding a note/not fully baked label to the sync user play queue setting by @eddyizm in https://github.com/eddyizm/tempo/commit/8ed0a4642bd0cd637c65e3115142596331fa7ef7
* fix: moved hardcoded italian save text to string template, updated with english and italian language xmls by @eddyizm in https://github.com/eddyizm/tempo/commit/26a5fb029a07752c9c0db0d08a89afd638772579
## New Contributors
* @mucahit-kaya made their first contribution in https://github.com/eddyizm/tempo/pull/50
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.12.0...v3.13.0
## [3.12.0](https://github.com/eddyizm/tempo/releases/tag/v3.12.0) (2025-08-15)
### What's Changed
* [chore]: add German translations for track info and home section strings (#29) by @BreadWare92 in https://github.com/eddyizm/tempo/pull/31
* [chore]: increased "Offline mode" text size, changed its color in dark theme by @jaime-grj in https://github.com/eddyizm/tempo/pull/33
* [chore]: Translations for sections by @skajmer in https://github.com/eddyizm/tempo/pull/30
* [chore]: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/36
* [fix]: Show placeholder string in TrackInfoDialog fields when there is no data by @jaime-grj in https://github.com/eddyizm/tempo/pull/37
* [feat]: added transcoding codec and bitrate info to PlayerControllerFragment, replace hardcoded strings by @jaime-grj in https://github.com/eddyizm/tempo/pull/38
* [chore]: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/39
* [feat]: show rating on song view by @eddyizm in https://github.com/eddyizm/tempo/pull/40
### New Contributors
* @BreadWare92 made their first contribution in https://github.com/eddyizm/tempo/pull/31
* @skajmer made their first contribution in https://github.com/eddyizm/tempo/pull/30
* @benoit-smith made their first contribution in https://github.com/eddyizm/tempo/pull/36
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.11.2...v3.12.0
## [3.11.2](https://github.com/eddyizm/tempo/releases/tag/v3.11.2) (2025-08-09)

View File

@@ -24,10 +24,19 @@ Tempo does not rely on magic algorithms to decide what you should listen to. Ins
## Fork
sha256 signing key fingerprint
`SHA256: B7:85:01:B9:34:D0:4E:0A:CA:8D:94:AF:D6:72:6A:4D:1D:CE:65:79:7F:1D:41:71:0F:64:3C:29:00:EB:1D:1D`
This fork is my attempt to keep development moving forward and merge in PR's that have been sitting for a while in the main repo. Thankful to @CappielloAntonio for the amazing app and hopefully we can continue to build on top of it. I will only be releasing on github and if I am not able to merge back to the main repo, I plan to rename the app to be able to publish it to fdroid and possibly google play? We will see.
Moved details to [CHANGELOG.md](https://github.com/eddyizm/tempo/blob/main/CHANGELOG.md)
Fork [**sponsorship here**](https://ko-fi.com/eddyizm).
## Usage
[Documentation](USAGE.md) (work in progress)
## Features
- **Subsonic Integration**: Tempo seamlessly integrates with your Subsonic server, providing you with easy access to your entire music collection on the go.
- **Sleek and Intuitive UI**: Enjoy a clean and user-friendly interface designed to enhance your music listening experience, tailored to your preferences and listening history.
@@ -41,21 +50,11 @@ Moved details to [CHANGELOG.md](https://github.com/eddyizm/tempo/blob/main/CHANG
- **Transcoding Support**: Activate transcoding of tracks on your Subsonic server, allowing you to set a transcoding profile for optimized streaming directly from the app. This feature requires support from your Subsonic server.
- **Android Auto Support**: Enjoy your favorite music on the go with full Android Auto integration, allowing you to seamlessly control and listen to your tracks directly from your mobile device while driving.
<p align="center">
<img src="mockup/feat/1_screenshot.png" width=200>
<img src="mockup/feat/2_screenshot.png" width=200>
<img src="mockup/feat/3_screenshot.png" width=200>
<img src="mockup/feat/4_screenshot.png" width=200>
<img src="mockup/feat/5_screenshot.png" width=200>
<img src="mockup/feat/6_screenshot.png" width=200>
<img src="mockup/feat/7_screenshot.png" width=200>
<img src="mockup/feat/8_screenshot.png" width=200>
</p>
## Sponsors
Thanks to the original repo/creator [CappielloAntonio](https://github.com/CappielloAntonio) (3.9.0)
Tempo is an open-source project developed and maintained solely by me. I would like to express my heartfelt thanks to all the users who have shown their love and support for Tempo. Your contributions and encouragement mean a lot to me, and they help drive the development and improvement of the app.
If you would like to sponsor the project and show your support, you can make a donation or contribution by visiting the [**sponsorship page**](https://www.buymeacoffee.com/a.cappiello). Your generosity will help cover the costs of development and further enhancements.
## Screenshot

146
USAGE.md Normal file
View File

@@ -0,0 +1,146 @@
# Tempo Usage Guide
[<- back home](README.md)
## Table of Contents
- [Prerequisites](#prerequisites)
- [Getting Started](#getting-started)
- [Server Configuration](#server-configuration)
- [Main Features](#main-features)
- [Navigation](#navigation)
- [Playback Controls](#playback-controls)
- [Favorites](#favorites)
- [Playlist Management](#playlist-management)
- [Android Auto](#android-auto)
- [Settings](#settings)
- [Troubleshooting](#troubleshooting)
## Prerequisites
**Important Notice**: This app is a Subsonic-compatible client and does not provide any music content itself. To use this application, you must have:
- An active Subsonic API server (or compatible service) already set up
- Valid login credentials for your Subsonic server
- Music content uploaded and organized on your server
### Verified backends
This app works with any service that implements the Subsonic API, including:
- [LMS - Lightweight Music Server](https://github.com/epoupon/lms) - *personal fave and my backend*
- [Navidrome](https://www.navidrome.org/)
- [Gonic](https://github.com/sentriz/gonic)
## Getting Started
### Installation
1. Download the APK from the [Releases](https://github.com/eddyizm/tempo/releases) section
2. Enable "Install from unknown sources" in your Android settings
3. Install the application
### First Launch
1. Open the application
2. You will be prompted to configure your server connection
3. Grant necessary permissions for media playback and background operation
## Server Configuration
### Initial Setup
**IN PROGRESS**
1. Enter your server URL (e.g., `https://your-subsonic-server.com`)
2. Provide your username and password
3. Test the connection to ensure proper configuration
### Advanced Settings
**TODO**
## Main Features
### Library View
**TODO**
### Now Playing Screen
**TODO**
## Navigation
### Bottom Navigation Bar
**IN PROGRESS**
- **Home**: Recently played and server recommendations
- **Library**: Your server's complete music collection
- **Download**: Locally downloaded files from server
## Playback Controls
### Streaming Controls
**TODO**
### Advanced Controls
**TODO**
## Favorites
### Favorites (aka heart aka star) to albums and artists
- Long pressing on an album gives you access to heart/unheart an album
<p align="center">
<img src="mockup/usage/fave_album.png" width=376>
</p>
- Long pressing on an artist cover gets you the same access to to heart/unheart an album
<p align="center">
<img src="mockup/usage/fave_artist.png" width=376>
</p>
## Playlist Management
### Server Playlists
**TODO**
### Creating Playlists
**TODO**
## Settings
## Android Auto
### Enabling on your head unit
- You have to enable Android Auto developer options, which are different from actual Android dev options. Then you have to enable "Unknown sources" in Android Auto, otherwise the app won't appear as it isn't downloaded from Play Store. (screenshots needed)
### Server Settings
**IN PROGRESS**
- Manage multiple server connections
- Configure sync intervals
- Set data usage limits for streaming
### Audio Settings
**IN PROGRESS**
- Streaming quality settings
- Offline caching preferences
### Appearance
**TODO**
## Troubleshooting
### Connection Issues
**TODO**
### Common Issues
**TODO**
### Support
For additional help:
- Question? Start a [Discussion](https://github.com/eddyizm/tempo/discussions)
- Open an [issue](https://github.com/eddyizm/tempo/issues) if you don't find a discussion solving your issue.
- Consult your Subsonic server's documentation
---
*Note: This app requires a pre-existing Subsonic-compatible server with music content.*

View File

@@ -10,9 +10,8 @@ android {
minSdkVersion 24
targetSdk 35
versionCode 27
versionName '3.11.2'
versionCode 33
versionName '3.16.0'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
javaCompileOptions {
@@ -23,8 +22,21 @@ android {
]
}
}
}
splits {
abi {
enable true
reset()
//noinspection ChromeOsAbiSupport
include 'armeabi-v7a', 'arm64-v8a'
universalApk false
}
}
flavorDimensions += "default"
productFlavors {
@@ -51,6 +63,11 @@ android {
debuggable false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
applicationIdSuffix ".debug"
debuggable true
}
}
compileOptions {

File diff suppressed because it is too large Load Diff

View File

@@ -73,5 +73,20 @@
android:name="autoStoreLocales"
android:value="true" />
</service>
<receiver
android:name=".widget.WidgetProvider4x1"
android:exported="false"
android:label="@string/widget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info"/>
</receiver>
</application>
</manifest>
</manifest>

View File

@@ -12,6 +12,7 @@ import com.cappielloantonio.tempo.database.converter.DateConverters;
import com.cappielloantonio.tempo.database.dao.ChronologyDao;
import com.cappielloantonio.tempo.database.dao.DownloadDao;
import com.cappielloantonio.tempo.database.dao.FavoriteDao;
import com.cappielloantonio.tempo.database.dao.LyricsDao;
import com.cappielloantonio.tempo.database.dao.PlaylistDao;
import com.cappielloantonio.tempo.database.dao.QueueDao;
import com.cappielloantonio.tempo.database.dao.RecentSearchDao;
@@ -20,6 +21,7 @@ import com.cappielloantonio.tempo.database.dao.SessionMediaItemDao;
import com.cappielloantonio.tempo.model.Chronology;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.model.Favorite;
import com.cappielloantonio.tempo.model.LyricsCache;
import com.cappielloantonio.tempo.model.Queue;
import com.cappielloantonio.tempo.model.RecentSearch;
import com.cappielloantonio.tempo.model.Server;
@@ -28,9 +30,9 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
@UnstableApi
@Database(
version = 11,
entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class},
autoMigrations = {@AutoMigration(from = 10, to = 11)}
version = 12,
entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class, LyricsCache.class},
autoMigrations = {@AutoMigration(from = 10, to = 11), @AutoMigration(from = 11, to = 12)}
)
@TypeConverters({DateConverters.class})
public abstract class AppDatabase extends RoomDatabase {
@@ -62,4 +64,6 @@ public abstract class AppDatabase extends RoomDatabase {
public abstract SessionMediaItemDao sessionMediaItemDao();
public abstract PlaylistDao playlistDao();
public abstract LyricsDao lyricsDao();
}

View File

@@ -15,6 +15,9 @@ public interface DownloadDao {
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
LiveData<List<Download>> getAll();
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
List<Download> getAllSync();
@Query("SELECT * FROM download WHERE id = :id")
Download getOne(String id);
@@ -30,6 +33,9 @@ public interface DownloadDao {
@Query("DELETE FROM download WHERE id = :id")
void delete(String id);
@Query("DELETE FROM download WHERE id IN (:ids)")
void deleteByIds(List<String> ids);
@Query("DELETE FROM download")
void deleteAll();
}

View File

@@ -0,0 +1,24 @@
package com.cappielloantonio.tempo.database.dao;
import androidx.lifecycle.LiveData;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import com.cappielloantonio.tempo.model.LyricsCache;
@Dao
public interface LyricsDao {
@Query("SELECT * FROM lyrics_cache WHERE song_id = :songId")
LyricsCache getOne(String songId);
@Query("SELECT * FROM lyrics_cache WHERE song_id = :songId")
LiveData<LyricsCache> observeOne(String songId);
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insert(LyricsCache lyricsCache);
@Query("DELETE FROM lyrics_cache WHERE song_id = :songId")
void delete(String songId);
}

View File

@@ -1,6 +1,7 @@
package com.cappielloantonio.tempo.glide;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.util.Log;
@@ -16,6 +17,7 @@ import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.signature.ObjectKey;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.R;
@@ -109,6 +111,18 @@ public class CustomGlideRequest {
return uri.toString();
}
public static void loadAlbumArtBitmap(Context context,
String coverId,
int size,
CustomTarget<Bitmap> target) {
String url = createUrl(coverId, size);
Glide.with(context)
.asBitmap()
.load(url)
.apply(createRequestOptions(context, coverId, ResourceType.Album))
.into(target);
}
public static class Builder {
private final RequestManager requestManager;
private Object item;

View File

@@ -0,0 +1,25 @@
package com.cappielloantonio.tempo.model
import androidx.annotation.Keep
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlin.jvm.JvmOverloads
@Keep
@Entity(tableName = "lyrics_cache")
data class LyricsCache @JvmOverloads constructor(
@PrimaryKey
@ColumnInfo(name = "song_id")
var songId: String,
@ColumnInfo(name = "artist")
var artist: String? = null,
@ColumnInfo(name = "title")
var title: String? = null,
@ColumnInfo(name = "lyrics")
var lyrics: String? = null,
@ColumnInfo(name = "structured_lyrics")
var structuredLyrics: String? = null,
@ColumnInfo(name = "updated_at")
var updatedAt: Long = System.currentTimeMillis()
)

View File

@@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.model
import android.net.Uri
import android.os.Bundle
import androidx.annotation.Keep
import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.RequestMetadata
import androidx.media3.common.MediaMetadata
@@ -243,6 +244,13 @@ class SessionMediaItem() {
.setAlbumTitle(album)
.setArtist(artist)
.setArtworkUri(artworkUri)
.setUserRating(HeartRating(starred != null))
.setSupportedCommands(
listOf(
Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON,
Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
)
)
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)

View File

@@ -2,10 +2,12 @@ package com.cappielloantonio.tempo.repository;
import androidx.annotation.NonNull;
import androidx.lifecycle.MutableLiveData;
import android.util.Log;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistInfo2;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.IndexID3;
@@ -13,12 +15,92 @@ import com.cappielloantonio.tempo.subsonic.models.IndexID3;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class ArtistRepository {
private final AlbumRepository albumRepository;
public ArtistRepository() {
this.albumRepository = new AlbumRepository();
}
public void getArtistAllSongs(String artistId, ArtistSongsCallback callback) {
Log.d("ArtistSync", "Getting albums for artist: " + artistId);
// Get the artist info first, which contains the albums
App.getSubsonicClientInstance(false)
.getBrowsingClient()
.getArtist(artistId)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null &&
response.body().getSubsonicResponse().getArtist() != null &&
response.body().getSubsonicResponse().getArtist().getAlbums() != null) {
List<AlbumID3> albums = response.body().getSubsonicResponse().getArtist().getAlbums();
Log.d("ArtistSync", "Got albums directly: " + albums.size());
if (!albums.isEmpty()) {
fetchAllAlbumSongsWithCallback(albums, callback);
} else {
Log.d("ArtistSync", "No albums found in artist response");
callback.onSongsCollected(new ArrayList<>());
}
} else {
Log.d("ArtistSync", "Failed to get artist info");
callback.onSongsCollected(new ArrayList<>());
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.d("ArtistSync", "Error getting artist info: " + t.getMessage());
callback.onSongsCollected(new ArrayList<>());
}
});
}
private void fetchAllAlbumSongsWithCallback(List<AlbumID3> albums, ArtistSongsCallback callback) {
if (albums == null || albums.isEmpty()) {
Log.d("ArtistSync", "No albums to process");
callback.onSongsCollected(new ArrayList<>());
return;
}
List<Child> allSongs = new ArrayList<>();
AtomicInteger remainingAlbums = new AtomicInteger(albums.size());
Log.d("ArtistSync", "Processing " + albums.size() + " albums");
for (AlbumID3 album : albums) {
Log.d("ArtistSync", "Getting tracks for album: " + album.getName());
MutableLiveData<List<Child>> albumTracks = albumRepository.getAlbumTracks(album.getId());
albumTracks.observeForever(songs -> {
Log.d("ArtistSync", "Got " + (songs != null ? songs.size() : 0) + " songs from album");
if (songs != null) {
allSongs.addAll(songs);
}
albumTracks.removeObservers(null);
int remaining = remainingAlbums.decrementAndGet();
Log.d("ArtistSync", "Remaining albums: " + remaining);
if (remaining == 0) {
Log.d("ArtistSync", "All albums processed. Total songs: " + allSongs.size());
callback.onSongsCollected(allSongs);
}
});
}
}
public interface ArtistSongsCallback {
void onSongsCollected(List<Child> songs);
}
public MutableLiveData<List<ArtistID3>> getStarredArtists(boolean random, int size) {
MutableLiveData<List<ArtistID3>> starredArtists = new MutableLiveData<>(new ArrayList<>());
@@ -89,7 +171,7 @@ public class ArtistRepository {
}
/*
* Metodo che mi restituisce le informazioni essenzionali dell'artista (cover, numero di album...)
* Method that returns essential artist information (cover, album number, etc.)
*/
public void getArtistInfo(List<ArtistID3> artists, MutableLiveData<List<ArtistID3>> list) {
List<ArtistID3> liveArtists = list.getValue();

View File

@@ -18,6 +18,20 @@ public class DownloadRepository {
return downloadDao.getAll();
}
public List<Download> getAllDownloads() {
GetAllDownloadsThreadSafe getDownloads = new GetAllDownloadsThreadSafe(downloadDao);
Thread thread = new Thread(getDownloads);
thread.start();
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
return getDownloads.getDownloads();
}
public Download getDownload(String id) {
Download download = null;
@@ -35,6 +49,24 @@ public class DownloadRepository {
return download;
}
private static class GetAllDownloadsThreadSafe implements Runnable {
private final DownloadDao downloadDao;
private List<Download> downloads;
public GetAllDownloadsThreadSafe(DownloadDao downloadDao) {
this.downloadDao = downloadDao;
}
@Override
public void run() {
downloads = downloadDao.getAllSync();
}
public List<Download> getDownloads() {
return downloads;
}
}
private static class GetDownloadThreadSafe implements Runnable {
private final DownloadDao downloadDao;
private final String id;
@@ -143,6 +175,12 @@ public class DownloadRepository {
thread.start();
}
public void delete(List<String> ids) {
DeleteMultipleThreadSafe delete = new DeleteMultipleThreadSafe(downloadDao, ids);
Thread thread = new Thread(delete);
thread.start();
}
private static class DeleteThreadSafe implements Runnable {
private final DownloadDao downloadDao;
private final String id;
@@ -157,4 +195,19 @@ public class DownloadRepository {
downloadDao.delete(id);
}
}
private static class DeleteMultipleThreadSafe implements Runnable {
private final DownloadDao downloadDao;
private final List<String> ids;
public DeleteMultipleThreadSafe(DownloadDao downloadDao, List<String> ids) {
this.downloadDao = downloadDao;
this.ids = ids;
}
@Override
public void run() {
downloadDao.deleteByIds(ids);
}
}
}

View File

@@ -0,0 +1,92 @@
package com.cappielloantonio.tempo.repository;
import androidx.lifecycle.LiveData;
import com.cappielloantonio.tempo.database.AppDatabase;
import com.cappielloantonio.tempo.database.dao.LyricsDao;
import com.cappielloantonio.tempo.model.LyricsCache;
public class LyricsRepository {
private final LyricsDao lyricsDao = AppDatabase.getInstance().lyricsDao();
public LyricsCache getLyrics(String songId) {
GetLyricsThreadSafe getLyricsThreadSafe = new GetLyricsThreadSafe(lyricsDao, songId);
Thread thread = new Thread(getLyricsThreadSafe);
thread.start();
try {
thread.join();
return getLyricsThreadSafe.getLyrics();
} catch (InterruptedException e) {
e.printStackTrace();
}
return null;
}
public LiveData<LyricsCache> observeLyrics(String songId) {
return lyricsDao.observeOne(songId);
}
public void insert(LyricsCache lyricsCache) {
InsertThreadSafe insert = new InsertThreadSafe(lyricsDao, lyricsCache);
Thread thread = new Thread(insert);
thread.start();
}
public void delete(String songId) {
DeleteThreadSafe delete = new DeleteThreadSafe(lyricsDao, songId);
Thread thread = new Thread(delete);
thread.start();
}
private static class GetLyricsThreadSafe implements Runnable {
private final LyricsDao lyricsDao;
private final String songId;
private LyricsCache lyricsCache;
public GetLyricsThreadSafe(LyricsDao lyricsDao, String songId) {
this.lyricsDao = lyricsDao;
this.songId = songId;
}
@Override
public void run() {
lyricsCache = lyricsDao.getOne(songId);
}
public LyricsCache getLyrics() {
return lyricsCache;
}
}
private static class InsertThreadSafe implements Runnable {
private final LyricsDao lyricsDao;
private final LyricsCache lyricsCache;
public InsertThreadSafe(LyricsDao lyricsDao, LyricsCache lyricsCache) {
this.lyricsDao = lyricsDao;
this.lyricsCache = lyricsCache;
}
@Override
public void run() {
lyricsDao.insert(lyricsCache);
}
}
private static class DeleteThreadSafe implements Runnable {
private final LyricsDao lyricsDao;
private final String songId;
public DeleteThreadSafe(LyricsDao lyricsDao, String songId) {
this.lyricsDao = lyricsDao;
this.songId = songId;
}
@Override
public void run() {
lyricsDao.delete(songId);
}
}
}

View File

@@ -81,20 +81,24 @@ public class PlaylistRepository {
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.updatePlaylist(playlistId, null, true, songsId, null)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show();
}
if (songsId.isEmpty()) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show();
} else{
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.updatePlaylist(playlistId, null, true, songsId, null)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show();
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show();
}
});
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show();
}
});
}
}
public void createPlaylist(String playlistId, String name, ArrayList<String> songsId) {
@@ -131,23 +135,6 @@ public class PlaylistRepository {
});
}
public void updatePlaylist(String playlistId, String name, boolean isPublic, ArrayList<String> songIdToAdd, ArrayList<Integer> songIndexToRemove) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.updatePlaylist(playlistId, name, isPublic, songIdToAdd, songIndexToRemove)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
}
public void deletePlaylist(String playlistId) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()

View File

@@ -0,0 +1,47 @@
package com.cappielloantonio.tempo.service
import android.media.audiofx.Equalizer
class EqualizerManager {
private var equalizer: Equalizer? = null
fun attachToSession(audioSessionId: Int): Boolean {
release()
if (audioSessionId != 0 && audioSessionId != -1) {
try {
equalizer = Equalizer(0, audioSessionId).apply {
enabled = true
}
return true
} catch (e: Exception) {
// Some devices may not support Equalizer or audio session may be invalid
equalizer = null
}
}
return false
}
fun setBandLevel(band: Short, level: Short) {
equalizer?.setBandLevel(band, level)
}
fun getNumberOfBands(): Short = equalizer?.numberOfBands ?: 0
fun getBandLevelRange(): ShortArray? = equalizer?.bandLevelRange
fun getCenterFreq(band: Short): Int? =
equalizer?.getCenterFreq(band)?.div(1000)
fun getBandLevel(band: Short): Short? =
equalizer?.getBandLevel(band)
fun setEnabled(enabled: Boolean) {
equalizer?.enabled = enabled
}
fun release() {
equalizer?.release()
equalizer = null
}
}

View File

@@ -1,11 +1,17 @@
package com.cappielloantonio.tempo.service;
import android.content.ComponentName;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Player;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.media3.session.SessionToken;
@@ -21,14 +27,79 @@ import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import java.lang.ref.WeakReference;
import java.util.List;
import java.util.concurrent.ExecutionException;
public class MediaManager {
private static final String TAG = "MediaManager";
private static WeakReference<MediaBrowser> attachedBrowserRef = new WeakReference<>(null);
public static void registerPlaybackObserver(
ListenableFuture<MediaBrowser> browserFuture,
PlaybackViewModel playbackViewModel
) {
if (browserFuture == null) return;
Futures.addCallback(browserFuture, new FutureCallback<MediaBrowser>() {
@Override
public void onSuccess(MediaBrowser browser) {
MediaBrowser current = attachedBrowserRef.get();
if (current != browser) {
browser.addListener(new Player.Listener() {
@Override
public void onEvents(@NonNull Player player, @NonNull Player.Events events) {
if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)
|| events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)
|| events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
String mediaId = player.getCurrentMediaItem() != null
? player.getCurrentMediaItem().mediaId
: null;
boolean playing = player.getPlaybackState() == Player.STATE_READY
&& player.getPlayWhenReady();
playbackViewModel.update(mediaId, playing);
}
}
});
String mediaId = browser.getCurrentMediaItem() != null
? browser.getCurrentMediaItem().mediaId
: null;
boolean playing = browser.getPlaybackState() == Player.STATE_READY && browser.getPlayWhenReady();
playbackViewModel.update(mediaId, playing);
attachedBrowserRef = new WeakReference<>(browser);
} else {
String mediaId = browser.getCurrentMediaItem() != null
? browser.getCurrentMediaItem().mediaId
: null;
boolean playing = browser.getPlaybackState() == Player.STATE_READY && browser.getPlayWhenReady();
playbackViewModel.update(mediaId, playing);
}
}
@Override
public void onFailure(@NonNull Throwable t) {
Log.e(TAG, "Failed to get MediaBrowser instance", t);
}
}, MoreExecutors.directExecutor());
}
public static void onBrowserReleased(@Nullable MediaBrowser released) {
MediaBrowser attached = attachedBrowserRef.get();
if (attached == released) {
attachedBrowserRef.clear();
}
}
public static void reset(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture) {
if (mediaBrowserListenableFuture != null) {
@@ -107,11 +178,24 @@ public class MediaManager {
mediaBrowserListenableFuture.addListener(() -> {
try {
if (mediaBrowserListenableFuture.isDone()) {
mediaBrowserListenableFuture.get().clearMediaItems();
mediaBrowserListenableFuture.get().setMediaItems(MappingUtil.mapMediaItems(media));
mediaBrowserListenableFuture.get().prepare();
mediaBrowserListenableFuture.get().seekTo(startIndex, 0);
mediaBrowserListenableFuture.get().play();
MediaBrowser browser = mediaBrowserListenableFuture.get();
browser.clearMediaItems();
browser.setMediaItems(MappingUtil.mapMediaItems(media));
browser.prepare();
Player.Listener timelineListener = new Player.Listener() {
@Override
public void onTimelineChanged(Timeline timeline, int reason) {
int itemCount = browser.getMediaItemCount();
if (itemCount > 0 && startIndex >= 0 && startIndex < itemCount) {
browser.seekTo(startIndex, 0);
browser.play();
browser.removeListener(this);
}
}
};
browser.addListener(timelineListener);
enqueueDatabase(media, true, 0);
}
} catch (ExecutionException | InterruptedException e) {
@@ -139,6 +223,25 @@ public class MediaManager {
}
}
public static void playDownloadedMediaItem(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, MediaItem mediaItem) {
if (mediaBrowserListenableFuture != null && mediaItem != null) {
mediaBrowserListenableFuture.addListener(() -> {
try {
if (mediaBrowserListenableFuture.isDone()) {
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
mediaBrowser.clearMediaItems();
mediaBrowser.setMediaItem(mediaItem);
mediaBrowser.prepare();
mediaBrowser.play();
clearDatabase();
}
} catch (ExecutionException | InterruptedException e) {
e.printStackTrace();
}
}, MoreExecutors.directExecutor());
}
}
public static void startRadio(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, InternetRadioStation internetRadioStation) {
if (mediaBrowserListenableFuture != null) {
mediaBrowserListenableFuture.addListener(() -> {

View File

@@ -5,6 +5,9 @@ import android.util.Log;
import com.cappielloantonio.tempo.subsonic.RetrofitClient;
import com.cappielloantonio.tempo.subsonic.Subsonic;
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
import com.cappielloantonio.tempo.util.Preferences;
import java.util.concurrent.TimeUnit;
import retrofit2.Call;
@@ -21,7 +24,15 @@ public class SystemClient {
public Call<ApiResponse> ping() {
Log.d(TAG, "ping()");
return systemService.ping(subsonic.getParams());
Call<ApiResponse> pingCall = systemService.ping(subsonic.getParams());
if (Preferences.isInUseServerAddressLocal()) {
pingCall.timeout()
.timeout(1, TimeUnit.SECONDS);
} else {
pingCall.timeout()
.timeout(3, TimeUnit.SECONDS);
}
return pingCall;
}
public Call<ApiResponse> getLicense() {

View File

@@ -1,11 +1,14 @@
package com.cappielloantonio.tempo.ui.activity;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
@@ -13,7 +16,10 @@ import androidx.annotation.NonNull;
import androidx.core.splashscreen.SplashScreen;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment;
@@ -56,6 +62,7 @@ public class MainActivity extends BaseActivity {
private BottomSheetBehavior bottomSheetBehavior;
ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver;
private Intent pendingDownloadPlaybackIntent;
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -77,12 +84,16 @@ public class MainActivity extends BaseActivity {
checkConnectionType();
getOpenSubsonicExtensions();
checkTempoUpdate();
maybeSchedulePlaybackIntent(getIntent());
}
@Override
protected void onStart() {
super.onStart();
pingServer();
initService();
consumePendingPlaybackIntent();
}
@Override
@@ -98,6 +109,14 @@ public class MainActivity extends BaseActivity {
bind = null;
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
maybeSchedulePlaybackIntent(intent);
consumePendingPlaybackIntent();
}
@Override
public void onBackPressed() {
if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED)
@@ -316,6 +335,7 @@ public class MainActivity extends BaseActivity {
Preferences.setSkipSilenceMode(false);
Preferences.setDataSavingMode(false);
Preferences.setStarredSyncEnabled(false);
Preferences.setStarredAlbumsSyncEnabled(false);
}
private void resetMusicSession() {
@@ -350,6 +370,7 @@ public class MainActivity extends BaseActivity {
Preferences.switchInUseServerAddress();
App.refreshSubsonicClient();
pingServer();
resetView();
} else {
Preferences.setOpenSubsonic(subsonicResponse.getOpenSubsonic() != null && subsonicResponse.getOpenSubsonic());
}
@@ -360,6 +381,7 @@ public class MainActivity extends BaseActivity {
Preferences.switchInUseServerAddress();
App.refreshSubsonicClient();
pingServer();
resetView();
} else {
mainViewModel.ping().observe(this, subsonicResponse -> {
if (subsonicResponse == null) {
@@ -375,6 +397,13 @@ public class MainActivity extends BaseActivity {
}
}
private void resetView() {
resetViewModel();
int id = Objects.requireNonNull(navController.getCurrentDestination()).getId();
navController.popBackStack(id, true);
navController.navigate(id);
}
private void getOpenSubsonicExtensions() {
if (Preferences.getToken() != null) {
mainViewModel.getOpenSubsonicExtensions().observe(this, openSubsonicExtensions -> {
@@ -407,4 +436,68 @@ public class MainActivity extends BaseActivity {
}
}
}
private void maybeSchedulePlaybackIntent(Intent intent) {
if (intent == null) return;
if (Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD.equals(intent.getAction())
|| intent.hasExtra(Constants.EXTRA_DOWNLOAD_URI)) {
pendingDownloadPlaybackIntent = new Intent(intent);
}
}
private void consumePendingPlaybackIntent() {
if (pendingDownloadPlaybackIntent == null) return;
Intent intent = pendingDownloadPlaybackIntent;
pendingDownloadPlaybackIntent = null;
playDownloadedMedia(intent);
}
private void playDownloadedMedia(Intent intent) {
String uriString = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_URI);
if (TextUtils.isEmpty(uriString)) {
return;
}
Uri uri = Uri.parse(uriString);
String mediaId = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID);
if (TextUtils.isEmpty(mediaId)) {
mediaId = uri.toString();
}
String title = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_TITLE);
String artist = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ARTIST);
String album = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ALBUM);
int duration = intent.getIntExtra(Constants.EXTRA_DOWNLOAD_DURATION, 0);
Bundle extras = new Bundle();
extras.putString("id", mediaId);
extras.putString("title", title);
extras.putString("artist", artist);
extras.putString("album", album);
extras.putString("uri", uri.toString());
extras.putString("type", Constants.MEDIA_TYPE_MUSIC);
extras.putInt("duration", duration);
MediaMetadata.Builder metadataBuilder = new MediaMetadata.Builder()
.setExtras(extras)
.setIsBrowsable(false)
.setIsPlayable(true);
if (!TextUtils.isEmpty(title)) metadataBuilder.setTitle(title);
if (!TextUtils.isEmpty(artist)) metadataBuilder.setArtist(artist);
if (!TextUtils.isEmpty(album)) metadataBuilder.setAlbumTitle(album);
MediaItem mediaItem = new MediaItem.Builder()
.setMediaId(mediaId)
.setMediaMetadata(metadataBuilder.build())
.setUri(uri)
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)
.setRequestMetadata(new MediaItem.RequestMetadata.Builder()
.setMediaUri(uri)
.setExtras(extras)
.build())
.build();
MediaManager.playDownloadedMediaItem(getMediaBrowserListenableFuture(), mediaItem);
}
}

View File

@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.adapter;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -23,17 +24,24 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueueAdapter.ViewHolder> {
private static final String TAG = "PlayerSongQueueAdapter";
private final ClickCallback click;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private List<Child> songs;
private String currentPlayingId;
private boolean isPlaying;
private List<Integer> currentPlayingPositions = Collections.emptyList();
public PlayerSongQueueAdapter(ClickCallback click) {
this.click = click;
this.songs = Collections.emptyList();
@@ -104,6 +112,46 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
} else {
holder.item.ratingIndicatorImageView.setVisibility(View.GONE);
}
holder.itemView.setOnClickListener(v -> {
mediaBrowserListenableFuture.addListener(() -> {
try {
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
int pos = holder.getBindingAdapterPosition();
Child s = songs.get(pos);
if (currentPlayingId != null && currentPlayingId.equals(s.getId())) {
if (isPlaying) {
mediaBrowser.pause();
} else {
mediaBrowser.play();
}
} else {
mediaBrowser.seekTo(pos, 0);
mediaBrowser.play();
}
} catch (Exception e) {
Log.w(TAG, "Error obtaining MediaBrowser", e);
}
}, MoreExecutors.directExecutor());
});
bindPlaybackState(holder, song);
}
private void bindPlaybackState(@NonNull PlayerSongQueueAdapter.ViewHolder holder, @NonNull Child song) {
boolean isCurrent = currentPlayingId != null && currentPlayingId.equals(song.getId());
if (isCurrent) {
holder.item.playPauseIcon.setVisibility(View.VISIBLE);
if (isPlaying) {
holder.item.playPauseIcon.setImageResource(R.drawable.ic_pause);
} else {
holder.item.playPauseIcon.setImageResource(R.drawable.ic_play);
}
holder.item.coverArtOverlay.setVisibility(View.VISIBLE);
} else {
holder.item.playPauseIcon.setVisibility(View.INVISIBLE);
holder.item.coverArtOverlay.setVisibility(View.INVISIBLE);
}
}
public List<Child> getItems() {
@@ -132,6 +180,46 @@ public class PlayerSongQueueAdapter extends RecyclerView.Adapter<PlayerSongQueue
this.mediaBrowserListenableFuture = mediaBrowserListenableFuture;
}
public void setPlaybackState(String mediaId, boolean playing) {
String oldId = this.currentPlayingId;
boolean oldPlaying = this.isPlaying;
List<Integer> oldPositions = currentPlayingPositions;
this.currentPlayingId = mediaId;
this.isPlaying = playing;
if (Objects.equals(oldId, mediaId) && oldPlaying == playing) {
List<Integer> newPositionsCheck = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
if (oldPositions.equals(newPositionsCheck)) {
return;
}
}
currentPlayingPositions = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
for (int pos : oldPositions) {
if (pos >= 0 && pos < songs.size()) {
notifyItemChanged(pos, "payload_playback");
}
}
for (int pos : currentPlayingPositions) {
if (!oldPositions.contains(pos) && pos >= 0 && pos < songs.size()) {
notifyItemChanged(pos, "payload_playback");
}
}
}
private List<Integer> findPositionsById(String id) {
if (id == null) return Collections.emptyList();
List<Integer> positions = new ArrayList<>();
for (int i = 0; i < songs.size(); i++) {
if (id.equals(songs.get(i).getId())) {
positions.add(i);
}
}
return positions;
}
public Child getItem(int id) {
return songs.get(id);
}

View File

@@ -1,6 +1,8 @@
package com.cappielloantonio.tempo.ui.adapter;
import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -9,7 +11,9 @@ import android.widget.Filterable;
import androidx.annotation.NonNull;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.lifecycle.LifecycleOwner;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.recyclerview.widget.RecyclerView;
import com.cappielloantonio.tempo.R;
@@ -21,8 +25,11 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.DiscTitle;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
import java.util.Collections;
@@ -30,6 +37,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
@UnstableApi
public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAdapter.ViewHolder> implements Filterable {
@@ -42,6 +50,11 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
private List<Child> songs;
private String currentFilter;
private String currentPlayingId;
private boolean isPlaying;
private List<Integer> currentPlayingPositions = Collections.emptyList();
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private final Filter filtering = new Filter() {
@Override
protected FilterResults performFiltering(CharSequence constraint) {
@@ -70,10 +83,16 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
protected void publishResults(CharSequence constraint, FilterResults results) {
songs = (List<Child>) results.values;
notifyDataSetChanged();
for (int pos : currentPlayingPositions) {
if (pos >= 0 && pos < songs.size()) {
notifyItemChanged(pos, "payload_playback");
}
}
}
};
public SongHorizontalAdapter(ClickCallback click, boolean showCoverArt, boolean showAlbum, AlbumID3 album) {
public SongHorizontalAdapter(LifecycleOwner lifecycleOwner, ClickCallback click, boolean showCoverArt, boolean showAlbum, AlbumID3 album) {
this.click = click;
this.showCoverArt = showCoverArt;
this.showAlbum = showAlbum;
@@ -81,6 +100,11 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
this.songsFull = Collections.emptyList();
this.currentFilter = "";
this.album = album;
setHasStableIds(false);
if (lifecycleOwner != null) {
MappingUtil.observeExternalAudioRefresh(lifecycleOwner, this::handleExternalAudioRefresh);
}
}
@NonNull
@@ -91,7 +115,16 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
}
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
public void onBindViewHolder(@NonNull ViewHolder holder, int position, @NonNull List<Object> payloads) {
if (!payloads.isEmpty() && payloads.contains("payload_playback")) {
bindPlaybackState(holder, songs.get(position));
} else {
super.onBindViewHolder(holder, position, payloads);
}
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
Child song = songs.get(position);
holder.item.searchResultSongTitleTextView.setText(song.getTitle());
@@ -109,10 +142,18 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
holder.item.trackNumberTextView.setText(MusicUtil.getReadableTrackNumber(holder.itemView.getContext(), song.getTrack()));
if (DownloadUtil.getDownloadTracker(holder.itemView.getContext()).isDownloaded(song.getId())) {
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.VISIBLE);
if (Preferences.getDownloadDirectoryUri() == null) {
if (DownloadUtil.getDownloadTracker(holder.itemView.getContext()).isDownloaded(song.getId())) {
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.VISIBLE);
} else {
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.GONE);
}
} else {
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.GONE);
if (ExternalAudioReader.getUri(song) != null) {
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.VISIBLE);
} else {
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.GONE);
}
}
if (showCoverArt) CustomGlideRequest.Builder
@@ -165,6 +206,39 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
} else {
holder.item.ratingIndicatorImageView.setVisibility(View.GONE);
}
bindPlaybackState(holder, song);
}
private void handleExternalAudioRefresh() {
if (Preferences.getDownloadDirectoryUri() != null) {
notifyDataSetChanged();
}
}
private void bindPlaybackState(@NonNull ViewHolder holder, @NonNull Child song) {
boolean isCurrent = currentPlayingId != null && currentPlayingId.equals(song.getId());
if (isCurrent) {
holder.item.playPauseIcon.setVisibility(View.VISIBLE);
if (isPlaying) {
holder.item.playPauseIcon.setImageResource(R.drawable.ic_pause);
} else {
holder.item.playPauseIcon.setImageResource(R.drawable.ic_play);
}
if (!showCoverArt) {
holder.item.trackNumberTextView.setVisibility(View.INVISIBLE);
} else {
holder.item.coverArtOverlay.setVisibility(View.VISIBLE);
}
} else {
holder.item.playPauseIcon.setVisibility(View.INVISIBLE);
if (!showCoverArt) {
holder.item.trackNumberTextView.setVisibility(View.VISIBLE);
} else {
holder.item.coverArtOverlay.setVisibility(View.INVISIBLE);
}
}
}
@Override
@@ -188,6 +262,46 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
return position;
}
public void setPlaybackState(String mediaId, boolean playing) {
String oldId = this.currentPlayingId;
boolean oldPlaying = this.isPlaying;
List<Integer> oldPositions = currentPlayingPositions;
this.currentPlayingId = mediaId;
this.isPlaying = playing;
if (Objects.equals(oldId, mediaId) && oldPlaying == playing) {
List<Integer> newPositionsCheck = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
if (oldPositions.equals(newPositionsCheck)) {
return;
}
}
currentPlayingPositions = mediaId != null ? findPositionsById(mediaId) : Collections.emptyList();
for (int pos : oldPositions) {
if (pos >= 0 && pos < songs.size()) {
notifyItemChanged(pos, "payload_playback");
}
}
for (int pos : currentPlayingPositions) {
if (!oldPositions.contains(pos) && pos >= 0 && pos < songs.size()) {
notifyItemChanged(pos, "payload_playback");
}
}
}
private List<Integer> findPositionsById(String id) {
if (id == null) return Collections.emptyList();
List<Integer> positions = new ArrayList<>();
for (int i = 0; i < songs.size(); i++) {
if (id.equals(songs.get(i).getId())) {
positions.add(i);
}
}
return positions;
}
@Override
public Filter getFilter() {
return filtering;
@@ -215,11 +329,29 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
}
public void onClick() {
int pos = getBindingAdapterPosition();
Child tappedSong = songs.get(pos);
Bundle bundle = new Bundle();
bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, new ArrayList<>(MusicUtil.limitPlayableMedia(songs, getBindingAdapterPosition())));
bundle.putInt(Constants.ITEM_POSITION, MusicUtil.getPlayableMediaPosition(songs, getBindingAdapterPosition()));
click.onMediaClick(bundle);
if (tappedSong.getId().equals(currentPlayingId)) {
Log.i("SongHorizontalAdapter", "Tapping on currently playing song, toggling playback");
try{
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
Log.i("SongHorizontalAdapter", "MediaBrowser retrieved, isPlaying: " + isPlaying);
if (isPlaying) {
mediaBrowser.pause();
} else {
mediaBrowser.play();
}
} catch (ExecutionException | InterruptedException e) {
Log.e("SongHorizontalAdapter", "Error getting MediaBrowser", e);
}
} else {
click.onMediaClick(bundle);
}
}
private boolean onLongClick() {
@@ -247,4 +379,8 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
notifyDataSetChanged();
}
public void setMediaBrowserListenableFuture(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture) {
this.mediaBrowserListenableFuture = mediaBrowserListenableFuture;
}
}

View File

@@ -3,6 +3,9 @@ package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog;
import android.os.Bundle;
import android.widget.Button;
import android.net.Uri;
import androidx.documentfile.provider.DocumentFile;
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
@@ -12,6 +15,9 @@ import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.DialogDeleteDownloadStorageBinding;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.ExternalDownloadMetadataStore;
import com.cappielloantonio.tempo.util.Preferences;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
@OptIn(markerClass = UnstableApi.class)
@@ -42,7 +48,21 @@ public class DeleteDownloadStorageDialog extends DialogFragment {
if (dialog != null) {
Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
positiveButton.setOnClickListener(v -> {
DownloadUtil.getDownloadTracker(requireContext()).removeAll();
if (Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(requireContext()).removeAll();
}
String uriString = Preferences.getDownloadDirectoryUri();
if (uriString != null) {
DocumentFile directory = DocumentFile.fromTreeUri(requireContext(), Uri.parse(uriString));
if (directory != null && directory.canWrite()) {
for (DocumentFile file : directory.listFiles()) {
file.delete();
}
}
ExternalAudioReader.refreshCache();
ExternalDownloadMetadataStore.clear();
}
dialog.dismiss();
});

View File

@@ -0,0 +1,63 @@
package com.cappielloantonio.tempo.ui.dialog;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.Preferences;
public class DownloadDirectoryPickerDialog extends DialogFragment {
private ActivityResultLauncher<Intent> folderPickerLauncher;
@NonNull
@Override
public android.app.Dialog onCreateDialog(Bundle savedInstanceState) {
// Register launcher *before* button triggers
folderPickerLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == android.app.Activity.RESULT_OK) {
Intent data = result.getData();
if (data != null) {
Uri uri = data.getData();
if (uri != null) {
requireContext().getContentResolver().takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
);
Preferences.setDownloadDirectoryUri(uri.toString());
ExternalAudioReader.refreshCache();
Toast.makeText(requireContext(), "Download directory set:\n" + uri.toString(), Toast.LENGTH_LONG).show();
}
}
}
}
);
return new MaterialAlertDialogBuilder(requireContext())
.setTitle("Set Download Directory")
.setMessage("Choose a folder where downloaded songs will be stored.")
.setPositiveButton("Choose Folder", (dialog, which) -> {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
folderPickerLauncher.launch(intent);
})
.setNegativeButton(android.R.string.cancel, null)
.create();
}
}

View File

@@ -34,6 +34,7 @@ public class DownloadStorageDialog extends DialogFragment {
.setTitle(R.string.download_storage_dialog_title)
.setPositiveButton(R.string.download_storage_external_dialog_positive_button, null)
.setNegativeButton(R.string.download_storage_internal_dialog_negative_button, null)
.setNeutralButton(R.string.download_storage_directory_dialog_neutral_button, null)
.create();
}
@@ -74,6 +75,20 @@ public class DownloadStorageDialog extends DialogFragment {
dialog.dismiss();
});
Button neutralButton = dialog.getButton(Dialog.BUTTON_NEUTRAL);
neutralButton.setOnClickListener(v -> {
int currentPreference = Preferences.getDownloadStoragePreference();
int newPreference = 2;
if (currentPreference != newPreference) {
Preferences.setDownloadStoragePreference(newPreference);
DownloadUtil.getDownloadTracker(requireContext()).removeAll();
dialogClickCallback.onNeutralClick();
}
dialog.dismiss();
});
}
}
}

View File

@@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog;
import android.os.Bundle;
import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
@@ -26,6 +27,7 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba
private PlaylistDialogHorizontalAdapter playlistDialogHorizontalAdapter;
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
@@ -97,8 +99,11 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba
@Override
public void onPlaylistClick(Bundle bundle) {
Playlist playlist = bundle.getParcelable(Constants.PLAYLIST_OBJECT);
playlistChooserViewModel.addSongsToPlaylist(playlist.getId());
dismiss();
if (playlistChooserViewModel.getSongsToAdd() != null && !playlistChooserViewModel.getSongsToAdd().isEmpty()) {
Playlist playlist = bundle.getParcelable(Constants.PLAYLIST_OBJECT);
playlistChooserViewModel.addSongsToPlaylist(this, getDialog(), playlist.getId());
} else {
Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_add_failure, Toast.LENGTH_SHORT).show();
}
}
}

View File

@@ -63,7 +63,11 @@ public class RatingDialog extends DialogFragment {
bind.ratingBar.setRating(song.getUserRating() != null ? song.getUserRating() : 0);
});
} else if (ratingViewModel.getAlbum() != null) {
ratingViewModel.getLiveAlbum().observe(this, album -> bind.ratingBar.setRating(/*album.getRating()*/ 0));
ratingViewModel.getLiveAlbum().observe(this, album -> {
if (album != null) {
bind.ratingBar.setRating(album.getUserRating() != null ? album.getUserRating() : 0);
}
});
} else if (ratingViewModel.getArtist() != null) {
ratingViewModel.getLiveArtist().observe(this, artist -> bind.ratingBar.setRating(/*artist.getRating()*/ 0));
}

View File

@@ -0,0 +1,88 @@
package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog;
import android.content.Context;
import android.os.Bundle;
import android.widget.Button;
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.DialogStarredAlbumSyncBinding;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.StarredAlbumsSyncViewModel;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.util.stream.Collectors;
@OptIn(markerClass = UnstableApi.class)
public class StarredAlbumSyncDialog extends DialogFragment {
private StarredAlbumsSyncViewModel starredAlbumsSyncViewModel;
private Runnable onCancel;
public StarredAlbumSyncDialog(Runnable onCancel) {
this.onCancel = onCancel;
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
DialogStarredAlbumSyncBinding bind = DialogStarredAlbumSyncBinding.inflate(getLayoutInflater());
starredAlbumsSyncViewModel = new ViewModelProvider(requireActivity()).get(StarredAlbumsSyncViewModel.class);
return new MaterialAlertDialogBuilder(getActivity())
.setView(bind.getRoot())
.setTitle(R.string.starred_album_sync_dialog_title)
.setPositiveButton(R.string.starred_sync_dialog_positive_button, null)
.setNeutralButton(R.string.starred_sync_dialog_neutral_button, null)
.setNegativeButton(R.string.starred_sync_dialog_negative_button, null)
.create();
}
@Override
public void onResume() {
super.onResume();
setButtonAction(requireContext());
}
private void setButtonAction(Context context) {
androidx.appcompat.app.AlertDialog dialog = (androidx.appcompat.app.AlertDialog) getDialog();
if (dialog != null) {
Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
positiveButton.setOnClickListener(v -> {
starredAlbumsSyncViewModel.getStarredAlbumSongs(requireActivity()).observe(this, allSongs -> {
if (allSongs != null && !allSongs.isEmpty()) {
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownloads(allSongs),
allSongs.stream().map(Download::new).collect(Collectors.toList())
);
}
dialog.dismiss();
});
});
Button neutralButton = dialog.getButton(Dialog.BUTTON_NEUTRAL);
neutralButton.setOnClickListener(v -> {
Preferences.setStarredAlbumsSyncEnabled(true);
dialog.dismiss();
});
Button negativeButton = dialog.getButton(Dialog.BUTTON_NEGATIVE);
negativeButton.setOnClickListener(v -> {
Preferences.setStarredAlbumsSyncEnabled(false);
if (onCancel != null) onCancel.run();
dialog.dismiss();
});
}
}
}

View File

@@ -0,0 +1,88 @@
package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog;
import android.content.Context;
import android.os.Bundle;
import android.widget.Button;
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.DialogStarredArtistSyncBinding;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.StarredArtistsSyncViewModel;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.util.stream.Collectors;
@OptIn(markerClass = UnstableApi.class)
public class StarredArtistSyncDialog extends DialogFragment {
private StarredArtistsSyncViewModel starredArtistsSyncViewModel;
private Runnable onCancel;
public StarredArtistSyncDialog(Runnable onCancel) {
this.onCancel = onCancel;
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
DialogStarredArtistSyncBinding bind = DialogStarredArtistSyncBinding.inflate(getLayoutInflater());
starredArtistsSyncViewModel = new ViewModelProvider(requireActivity()).get(StarredArtistsSyncViewModel.class);
return new MaterialAlertDialogBuilder(getActivity())
.setView(bind.getRoot())
.setTitle(R.string.starred_artist_sync_dialog_title)
.setPositiveButton(R.string.starred_sync_dialog_positive_button, null)
.setNeutralButton(R.string.starred_sync_dialog_neutral_button, null)
.setNegativeButton(R.string.starred_sync_dialog_negative_button, null)
.create();
}
@Override
public void onResume() {
super.onResume();
setButtonAction(requireContext());
}
private void setButtonAction(Context context) {
androidx.appcompat.app.AlertDialog dialog = (androidx.appcompat.app.AlertDialog) getDialog();
if (dialog != null) {
Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
positiveButton.setOnClickListener(v -> {
starredArtistsSyncViewModel.getStarredArtistSongs(requireActivity()).observe(this, allSongs -> {
if (allSongs != null && !allSongs.isEmpty()) {
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownloads(allSongs),
allSongs.stream().map(Download::new).collect(Collectors.toList())
);
}
dialog.dismiss();
});
});
Button neutralButton = dialog.getButton(Dialog.BUTTON_NEUTRAL);
neutralButton.setOnClickListener(v -> {
Preferences.setStarredArtistsSyncEnabled(true);
dialog.dismiss();
});
Button negativeButton = dialog.getButton(Dialog.BUTTON_NEGATIVE);
negativeButton.setOnClickListener(v -> {
Preferences.setStarredArtistsSyncEnabled(false);
if (onCancel != null) onCancel.run();
dialog.dismiss();
});
}
}
}

View File

@@ -26,6 +26,12 @@ import java.util.stream.Collectors;
public class StarredSyncDialog extends DialogFragment {
private StarredSyncViewModel starredSyncViewModel;
private Runnable onCancel;
public StarredSyncDialog(Runnable onCancel) {
this.onCancel = onCancel;
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
@@ -55,7 +61,7 @@ public class StarredSyncDialog extends DialogFragment {
Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
positiveButton.setOnClickListener(v -> {
starredSyncViewModel.getStarredTracks(requireActivity()).observe(requireActivity(), songs -> {
if (songs != null) {
if (songs != null && Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownloads(songs),
songs.stream().map(Download::new).collect(Collectors.toList())
@@ -75,6 +81,7 @@ public class StarredSyncDialog extends DialogFragment {
Button negativeButton = dialog.getButton(Dialog.BUTTON_NEGATIVE);
negativeButton.setOnClickListener(v -> {
Preferences.setStarredSyncEnabled(false);
if (onCancel != null) onCancel.run();
dialog.dismiss();
});
}

View File

@@ -15,6 +15,8 @@ import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.util.Objects;
public class TrackInfoDialog extends DialogFragment {
private DialogTrackInfoBinding bind;
@@ -51,7 +53,12 @@ public class TrackInfoDialog extends DialogFragment {
private void setTrackInfo() {
bind.trakTitleInfoTextView.setText(mediaMetadata.title);
bind.trakArtistInfoTextView.setText(mediaMetadata.artist);
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))
: "");
if (mediaMetadata.extras != null) {
CustomGlideRequest.Builder
@@ -62,20 +69,20 @@ public class TrackInfoDialog extends DialogFragment {
bind.titleValueSector.setText(mediaMetadata.extras.getString("title", getString(R.string.label_placeholder)));
bind.albumValueSector.setText(mediaMetadata.extras.getString("album", getString(R.string.label_placeholder)));
bind.artistValueSector.setText(mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder)));
bind.trackNumberValueSector.setText(String.valueOf(mediaMetadata.extras.getInt("track", 0)));
bind.yearValueSector.setText(String.valueOf(mediaMetadata.extras.getInt("year", 0)));
bind.trackNumberValueSector.setText(mediaMetadata.extras.getInt("track", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("track", 0)) : getString(R.string.label_placeholder));
bind.yearValueSector.setText(mediaMetadata.extras.getInt("year", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("year", 0)) : getString(R.string.label_placeholder));
bind.genreValueSector.setText(mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder)));
bind.sizeValueSector.setText(MusicUtil.getReadableByteCount(mediaMetadata.extras.getLong("size", 0)));
bind.sizeValueSector.setText(mediaMetadata.extras.getLong("size", 0) != 0 ? MusicUtil.getReadableByteCount(mediaMetadata.extras.getLong("size", 0)) : getString(R.string.label_placeholder));
bind.contentTypeValueSector.setText(mediaMetadata.extras.getString("contentType", getString(R.string.label_placeholder)));
bind.suffixValueSector.setText(mediaMetadata.extras.getString("suffix", getString(R.string.label_placeholder)));
bind.transcodedContentTypeValueSector.setText(mediaMetadata.extras.getString("transcodedContentType", getString(R.string.label_placeholder)));
bind.transcodedSuffixValueSector.setText(mediaMetadata.extras.getString("transcodedSuffix", getString(R.string.label_placeholder)));
bind.durationValueSector.setText(MusicUtil.getReadableDurationString(mediaMetadata.extras.getInt("duration", 0), false));
bind.bitrateValueSector.setText(mediaMetadata.extras.getInt("bitrate", 0) + " kbps");
bind.durationValueSector.setText(mediaMetadata.extras.getInt("duration", 0) != 0 ? MusicUtil.getReadableDurationString(mediaMetadata.extras.getInt("duration", 0), false) : getString(R.string.label_placeholder));
bind.bitrateValueSector.setText(mediaMetadata.extras.getInt("bitrate", 0) != 0 ? mediaMetadata.extras.getInt("bitrate", 0) + " kbps" : getString(R.string.label_placeholder));
bind.samplingRateValueSector.setText(mediaMetadata.extras.getInt("samplingRate", 0) != 0 ? mediaMetadata.extras.getInt("samplingRate", 0) + " Hz" : getString(R.string.label_placeholder));
bind.bitDepthValueSector.setText(mediaMetadata.extras.getInt("bitDepth", 0) != 0 ? mediaMetadata.extras.getInt("bitDepth", 0) + " bits" : getString(R.string.label_placeholder));
bind.pathValueSector.setText(mediaMetadata.extras.getString("path", getString(R.string.label_placeholder)));
bind.discNumberValueSector.setText(String.valueOf(mediaMetadata.extras.getInt("discNumber", 0)));
bind.discNumberValueSector.setText(mediaMetadata.extras.getInt("discNumber", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("discNumber", 0)) : getString(R.string.label_placeholder));
}
}

View File

@@ -12,7 +12,7 @@ import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.PopupMenu;
import android.widget.SearchView;
import androidx.appcompat.widget.SearchView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -145,7 +145,7 @@ public class AlbumListPageFragment extends Fragment implements ClickCallback {
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
inflater.inflate(R.menu.toolbar_menu, menu);
inflater.inflate(R.menu.artist_list_menu, menu);
MenuItem searchItem = menu.findItem(R.id.action_search);

View File

@@ -4,6 +4,7 @@ import android.content.ComponentName;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcelable;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@@ -27,16 +28,21 @@ import com.cappielloantonio.tempo.databinding.FragmentAlbumPageBinding;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.AlbumPageViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
@@ -49,6 +55,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
private FragmentAlbumPageBinding bind;
private MainActivity activity;
private AlbumPageViewModel albumPageViewModel;
private PlaybackViewModel playbackViewModel;
private SongHorizontalAdapter songHorizontalAdapter;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@@ -71,6 +78,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind = FragmentAlbumPageBinding.inflate(inflater, container, false);
View view = bind.getRoot();
albumPageViewModel = new ViewModelProvider(requireActivity()).get(AlbumPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
initAppBar();
@@ -88,6 +96,14 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
super.onStart();
initializeMediaBrowser();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
public void onResume() {
super.onResume();
if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
}
@Override
@@ -104,9 +120,26 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == R.id.action_rate_album) {
Bundle bundle = new Bundle();
AlbumID3 album = albumPageViewModel.getAlbum().getValue();
bundle.putParcelable(Constants.ALBUM_OBJECT, (Parcelable) album);
RatingDialog dialog = new RatingDialog();
dialog.setArguments(bundle);
dialog.show(requireActivity().getSupportFragmentManager(), null);
return true;
}
if (item.getItemId() == R.id.action_download_album) {
albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> {
DownloadUtil.getDownloadTracker(requireContext()).download(MappingUtil.mapDownloads(songs), songs.stream().map(Download::new).collect(Collectors.toList()));
if (Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(requireContext()).download(
MappingUtil.mapDownloads(songs),
songs.stream().map(Download::new).collect(Collectors.toList())
);
} else {
songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
}
});
return true;
}
@@ -256,10 +289,15 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.songRecyclerView.setHasFixedSize(true);
songHorizontalAdapter = new SongHorizontalAdapter(this, false, false, album);
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, false, false, album);
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture();
reapplyPlayback();
albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> songHorizontalAdapter.setItems(songs));
albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> {
songHorizontalAdapter.setItems(songs);
reapplyPlayback();
});
}
});
}
@@ -282,4 +320,31 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
public void onMediaLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
}
private void observePlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (songHorizontalAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyPlayback() {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void setMediaBrowserListenableFuture() {
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
}

View File

@@ -13,6 +13,7 @@ import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.widget.PopupMenu;
import android.widget.SearchView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -24,6 +25,8 @@ import androidx.navigation.Navigation;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.util.Log;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.FragmentArtistCatalogueBinding;
import com.cappielloantonio.tempo.helper.recyclerview.GridItemDecoration;
@@ -32,6 +35,10 @@ import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.ArtistCatalogueAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.viewmodel.ArtistCatalogueViewModel;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import java.util.ArrayList;
import java.util.List;
@UnstableApi
public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
@@ -125,23 +132,50 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
SearchView searchView = (SearchView) searchItem.getActionView();
searchView.setImeOptions(EditorInfo.IME_ACTION_DONE);
searchView.setQueryHint(getString(R.string.filter_artist));
searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
@Override
public boolean onQueryTextSubmit(String query) {
searchView.clearFocus();
return false;
// this toast may be overkill...
Toast.makeText(requireContext(), "Search: " + query, Toast.LENGTH_SHORT).show();
filterArtists(query);
return true;
}
@Override
public boolean onQueryTextChange(String newText) {
artistAdapter.getFilter().filter(newText);
return false;
filterArtists(newText);
return true;
}
});
searchView.setPadding(-32, 0, 0, 0);
}
private void filterArtists(String query) {
List<ArtistID3> allArtists = artistCatalogueViewModel.getArtistList().getValue();
if (allArtists == null || allArtists.isEmpty()) {
return;
}
if (query == null || query.trim().isEmpty()) {
artistAdapter.setItems(allArtists);
} else {
String searchQuery = query.toLowerCase().trim();
List<ArtistID3> filteredArtists = new ArrayList<>();
for (ArtistID3 artist : allArtists) {
if (artist.getName() != null &&
artist.getName().toLowerCase().contains(searchQuery)) {
filteredArtists.add(artist);
}
}
artistAdapter.setItems(filteredArtists);
}
}
private void hideKeyboard(View view) {
InputMethodManager imm = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
imm.hideSoftInputFromWindow(view.getWindowToken(), 0);

View File

@@ -29,19 +29,16 @@ import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.AlbumArtistPageOrSimilarAdapter;
import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter;
import com.cappielloantonio.tempo.ui.adapter.ArtistCatalogueAdapter;
import com.cappielloantonio.tempo.ui.adapter.ArtistSimilarAdapter;
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.ArtistPageViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@UnstableApi
@@ -49,6 +46,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
private FragmentArtistPageBinding bind;
private MainActivity activity;
private ArtistPageViewModel artistPageViewModel;
private PlaybackViewModel playbackViewModel;
private SongHorizontalAdapter songHorizontalAdapter;
private AlbumCatalogueAdapter albumCatalogueAdapter;
@@ -63,6 +61,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
bind = FragmentArtistPageBinding.inflate(inflater, container, false);
View view = bind.getRoot();
artistPageViewModel = new ViewModelProvider(requireActivity()).get(ArtistPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
initAppBar();
@@ -80,6 +79,13 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
super.onStart();
initializeMediaBrowser();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
public void onResume() {
super.onResume();
if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
}
@Override
@@ -159,7 +165,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
bind.artistPageRadioButton.setOnClickListener(v -> {
artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), songs -> {
if (!songs.isEmpty()) {
if (songs != null && !songs.isEmpty()) {
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
activity.setBottomSheetInPeek(true);
} else {
@@ -172,8 +178,10 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
private void initTopSongsView() {
bind.mostStreamedSongRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
songHorizontalAdapter = new SongHorizontalAdapter(this, true, true, null);
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, true, null);
bind.mostStreamedSongRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture();
reapplyPlayback();
artistPageViewModel.getArtistTopSongList().observe(getViewLifecycleOwner(), songs -> {
if (songs == null) {
if (bind != null) bind.artistPageTopSongsSector.setVisibility(View.GONE);
@@ -183,6 +191,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
if (bind != null)
bind.artistPageShuffleButton.setEnabled(!songs.isEmpty());
songHorizontalAdapter.setItems(songs);
reapplyPlayback();
}
});
}
@@ -273,4 +282,31 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
public void onArtistLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.artistBottomSheetDialog, bundle);
}
private void observePlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (songHorizontalAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyPlayback() {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void setMediaBrowserListenableFuture() {
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
}

View File

@@ -33,7 +33,9 @@ import com.cappielloantonio.tempo.ui.adapter.MusicDirectoryAdapter;
import com.cappielloantonio.tempo.ui.dialog.DownloadDirectoryDialog;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.DirectoryViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@@ -109,10 +111,14 @@ public class DirectoryFragment extends Fragment implements ClickCallback {
directoryViewModel.loadMusicDirectory(getArguments().getString(Constants.MUSIC_DIRECTORY_ID)).observe(getViewLifecycleOwner(), directory -> {
if (isVisible() && getActivity() != null) {
List<Child> songs = directory.getChildren().stream().filter(child -> !child.isDir()).collect(Collectors.toList());
DownloadUtil.getDownloadTracker(requireContext()).download(
MappingUtil.mapDownloads(songs),
songs.stream().map(Download::new).collect(Collectors.toList())
);
if (Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(requireContext()).download(
MappingUtil.mapDownloads(songs),
songs.stream().map(Download::new).collect(Collectors.toList())
);
} else {
songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
}
}
});
}

View File

@@ -28,11 +28,17 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.DownloadHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.DownloadViewModel;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.common.util.concurrent.ListenableFuture;
import android.content.Intent;
import android.app.Activity;
import android.net.Uri;
import android.widget.Toast;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -40,6 +46,7 @@ import java.util.Objects;
@UnstableApi
public class DownloadFragment extends Fragment implements ClickCallback {
private static final String TAG = "DownloadFragment";
private static final int REQUEST_CODE_PICK_DIRECTORY = 1002;
private FragmentDownloadBinding bind;
private MainActivity activity;
@@ -129,8 +136,27 @@ public class DownloadFragment extends Fragment implements ClickCallback {
}
});
downloadViewModel.getRefreshResult().observe(getViewLifecycleOwner(), count -> {
if (count == null || bind == null) {
return;
}
if (count == -1) {
Toast.makeText(requireContext(), R.string.download_refresh_no_directory, Toast.LENGTH_SHORT).show();
} else if (count == 0) {
Toast.makeText(requireContext(), R.string.download_refresh_no_changes, Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(
requireContext(),
getResources().getQuantityString(R.plurals.download_refresh_removed, count, count),
Toast.LENGTH_SHORT
).show();
}
});
bind.downloadedGroupByImageView.setOnClickListener(view -> showPopupMenu(view, R.menu.download_popup_menu));
bind.downloadedGoBackImageView.setOnClickListener(view -> downloadViewModel.popViewStack());
bind.downloadedRefreshImageView.setOnClickListener(view -> downloadViewModel.refreshExternalDownloads());
}
private void finishDownloadView(List<Child> songs) {
@@ -216,6 +242,10 @@ public class DownloadFragment extends Fragment implements ClickCallback {
downloadViewModel.initViewStack(new DownloadStack(Constants.DOWNLOAD_TYPE_YEAR, null));
Preferences.setDefaultDownloadViewType(Constants.DOWNLOAD_TYPE_YEAR);
return true;
} else if (menuItem.getItemId() == R.id.menu_download_set_directory) {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
startActivityForResult(intent, REQUEST_CODE_PICK_DIRECTORY);
return true;
}
return false;
@@ -267,4 +297,21 @@ public class DownloadFragment extends Fragment implements ClickCallback {
public void onDownloadGroupLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.downloadBottomSheetDialog, bundle);
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE_PICK_DIRECTORY && resultCode == Activity.RESULT_OK) {
Uri uri = data.getData();
if (uri != null) {
requireContext().getContentResolver().takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
);
Preferences.setDownloadDirectoryUri(uri.toString());
ExternalAudioReader.refreshCache();
Toast.makeText(requireContext(), "Download directory set", Toast.LENGTH_SHORT).show();
}
}
}
}

View File

@@ -0,0 +1,237 @@
package com.cappielloantonio.tempo.ui.fragment
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.annotation.OptIn
import androidx.fragment.app.Fragment
import androidx.media3.common.util.UnstableApi
import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.service.EqualizerManager
import com.cappielloantonio.tempo.service.MediaService
import com.cappielloantonio.tempo.util.Preferences
class EqualizerFragment : Fragment() {
private var equalizerManager: EqualizerManager? = null
private lateinit var eqBandsContainer: LinearLayout
private lateinit var eqSwitch: Switch
private lateinit var resetButton: Button
private lateinit var safeSpace: Space
private val bandSeekBars = mutableListOf<SeekBar>()
private val connection = object : ServiceConnection {
@OptIn(UnstableApi::class)
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as MediaService.LocalBinder
equalizerManager = binder.getEqualizerManager()
initUI()
restoreEqualizerPreferences()
}
override fun onServiceDisconnected(arg0: ComponentName) {
equalizerManager = null
}
}
@OptIn(UnstableApi::class)
override fun onStart() {
super.onStart()
Intent(requireContext(), MediaService::class.java).also { intent ->
intent.action = MediaService.ACTION_BIND_EQUALIZER
requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
}
override fun onStop() {
super.onStop()
requireActivity().unbindService(connection)
equalizerManager = null
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val root = inflater.inflate(R.layout.fragment_equalizer, container, false)
eqSwitch = root.findViewById(R.id.equalizer_switch)
eqSwitch.isChecked = Preferences.isEqualizerEnabled()
eqSwitch.jumpDrawablesToCurrentState()
return root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
eqBandsContainer = view.findViewById(R.id.eq_bands_container)
resetButton = view.findViewById(R.id.equalizer_reset_button)
safeSpace = view.findViewById(R.id.equalizer_bottom_space)
}
private fun initUI() {
val manager = equalizerManager
val notSupportedView = view?.findViewById<LinearLayout>(R.id.equalizer_not_supported_container)
val switchRow = view?.findViewById<View>(R.id.equalizer_switch_row)
if (manager == null || manager.getNumberOfBands().toInt() == 0) {
switchRow?.visibility = View.GONE
resetButton.visibility = View.GONE
eqBandsContainer.visibility = View.GONE
safeSpace.visibility = View.GONE
notSupportedView?.visibility = View.VISIBLE
return
}
notSupportedView?.visibility = View.GONE
switchRow?.visibility = View.VISIBLE
resetButton.visibility = View.VISIBLE
eqBandsContainer.visibility = View.VISIBLE
safeSpace.visibility = View.VISIBLE
eqSwitch.setOnCheckedChangeListener(null)
updateUiEnabledState(eqSwitch.isChecked)
eqSwitch.setOnCheckedChangeListener { _, isChecked ->
manager.setEnabled(isChecked)
Preferences.setEqualizerEnabled(isChecked)
updateUiEnabledState(isChecked)
}
createBandSliders()
resetButton.setOnClickListener {
resetEqualizer()
saveBandLevelsToPreferences()
}
}
private fun updateUiEnabledState(isEnabled: Boolean) {
resetButton.isEnabled = isEnabled
bandSeekBars.forEach { it.isEnabled = isEnabled }
}
private fun formatDb(value: Int): String = if (value > 0) "+$value dB" else "$value dB"
private fun createBandSliders() {
val manager = equalizerManager ?: return
eqBandsContainer.removeAllViews()
bandSeekBars.clear()
val bands = manager.getNumberOfBands()
val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500)
val minLevelDb = bandLevelRange[0] / 100
val maxLevelDb = bandLevelRange[1] / 100
val savedLevels = Preferences.getEqualizerBandLevels(bands)
for (i in 0 until bands) {
val band = i.toShort()
val freq = manager.getCenterFreq(band) ?: 0
val row = LinearLayout(requireContext()).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
val topBottomMarginDp = 16
topMargin = topBottomMarginDp.dpToPx(context)
bottomMargin = topBottomMarginDp.dpToPx(context)
}
setPadding(0, 8, 0, 8)
}
val freqLabel = TextView(requireContext(), null, 0, R.style.LabelSmall).apply {
text = if (freq >= 1000) {
if (freq % 1000 == 0) {
"${freq / 1000} kHz"
} else {
String.format("%.1f kHz", freq / 1000f)
}
} else {
"$freq Hz"
}
gravity = Gravity.START
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f)
}
row.addView(freqLabel)
val initialLevelDb = (savedLevels.getOrNull(i) ?: (manager.getBandLevel(band) ?: 0)) / 100
val dbLabel = TextView(requireContext(), null, 0, R.style.LabelSmall).apply {
text = formatDb(initialLevelDb)
setPadding(12, 0, 0, 0)
gravity = Gravity.END
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 2f)
}
val seekBar = SeekBar(requireContext()).apply {
max = maxLevelDb - minLevelDb
progress = initialLevelDb - minLevelDb
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 6f)
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
val thisLevelDb = progress + minLevelDb
if (fromUser) {
manager.setBandLevel(band, (thisLevelDb * 100).toShort())
saveBandLevelsToPreferences()
}
dbLabel.text = formatDb(thisLevelDb)
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
})
}
bandSeekBars.add(seekBar)
row.addView(seekBar)
row.addView(dbLabel)
eqBandsContainer.addView(row)
}
}
private fun resetEqualizer() {
val manager = equalizerManager ?: return
val bands = manager.getNumberOfBands()
val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500)
val minLevelDb = bandLevelRange[0] / 100
val midLevelDb = 0
for (i in 0 until bands) {
manager.setBandLevel(i.toShort(), (0).toShort())
bandSeekBars.getOrNull(i)?.progress = midLevelDb - minLevelDb
}
Preferences.setEqualizerBandLevels(ShortArray(bands.toInt()))
}
private fun saveBandLevelsToPreferences() {
val manager = equalizerManager ?: return
val bands = manager.getNumberOfBands()
val levels = ShortArray(bands.toInt()) { i -> manager.getBandLevel(i.toShort()) ?: 0 }
Preferences.setEqualizerBandLevels(levels)
}
private fun restoreEqualizerPreferences() {
val manager = equalizerManager ?: return
eqSwitch.isChecked = Preferences.isEqualizerEnabled()
updateUiEnabledState(eqSwitch.isChecked)
val bands = manager.getNumberOfBands()
val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500)
val minLevelDb = bandLevelRange[0] / 100
val savedLevels = Preferences.getEqualizerBandLevels(bands)
for (i in 0 until bands) {
val savedDb = savedLevels[i] / 100
manager.setBandLevel(i.toShort(), (savedDb * 100).toShort())
bandSeekBars.getOrNull(i)?.progress = savedDb - minLevelDb
}
}
}
private fun Int.dpToPx(context: Context): Int =
(this * context.resources.displayMetrics.density).toInt()

View File

@@ -9,6 +9,7 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.PopupMenu;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -39,6 +40,8 @@ import com.cappielloantonio.tempo.service.MediaManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Share;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter;
import com.cappielloantonio.tempo.ui.adapter.AlbumHorizontalAdapter;
@@ -59,9 +62,12 @@ import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.UIUtil;
import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.android.material.snackbar.Snackbar;
import com.google.common.util.concurrent.ListenableFuture;
import androidx.media3.common.MediaItem;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@@ -73,6 +79,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
private FragmentHomeTabMusicBinding bind;
private MainActivity activity;
private HomeViewModel homeViewModel;
private PlaybackViewModel playbackViewModel;
private DiscoverSongAdapter discoverSongAdapter;
private SimilarTrackAdapter similarMusicAdapter;
@@ -100,6 +107,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind = FragmentHomeTabMusicBinding.inflate(inflater, container, false);
View view = bind.getRoot();
homeViewModel = new ViewModelProvider(requireActivity()).get(HomeViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
@@ -111,6 +119,8 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
super.onViewCreated(view, savedInstanceState);
initSyncStarredView();
initSyncStarredAlbumsView();
initSyncStarredArtistsView();
initDiscoverSongSlideView();
initSimilarSongView();
initArtistRadio();
@@ -136,12 +146,18 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
super.onStart();
initializeMediaBrowser();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observeStarredSongsPlayback();
observeTopSongsPlayback();
}
@Override
public void onResume() {
super.onResume();
refreshSharesView();
if (topSongAdapter != null) setTopSongsMediaBrowserListenableFuture();
if (starredSongAdapter != null) setStarredSongsMediaBrowserListenableFuture();
}
@Override
@@ -263,7 +279,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
}
private void initSyncStarredView() {
if (Preferences.isStarredSyncEnabled()) {
if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
homeViewModel.getAllStarredTracks().observeForever(new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> songs) {
@@ -314,6 +330,174 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
});
}
private void initSyncStarredAlbumsView() {
if (Preferences.isStarredAlbumsSyncEnabled()) {
homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer<List<AlbumID3>>() {
@Override
public void onChanged(List<AlbumID3> albums) {
if (albums != null && !albums.isEmpty()) {
checkIfAlbumsNeedSync(albums);
}
}
});
}
bind.homeSyncStarredAlbumsCancel.setOnClickListener(v -> {
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
});
bind.homeSyncStarredAlbumsDownload.setOnClickListener(v -> {
homeViewModel.getAllStarredAlbumSongs().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> allSongs) {
if (allSongs != null && !allSongs.isEmpty()) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
int songsToDownload = 0;
for (Child song : allSongs) {
if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song));
songsToDownload++;
}
}
if (songsToDownload > 0) {
Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
Toast.LENGTH_SHORT).show();
}
}
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
}
});
});
}
private void checkIfAlbumsNeedSync(List<AlbumID3> albums) {
homeViewModel.getAllStarredAlbumSongs().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> allSongs) {
if (allSongs != null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
int songsToDownload = 0;
List<String> albumsNeedingSync = new ArrayList<>();
for (AlbumID3 album : albums) {
boolean albumNeedsSync = false;
// Check if any songs from this album need downloading
for (Child song : allSongs) {
if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) &&
!manager.isDownloaded(song.getId())) {
songsToDownload++;
albumNeedsSync = true;
}
}
if (albumNeedsSync) {
albumsNeedingSync.add(album.getName());
}
}
if (songsToDownload > 0) {
bind.homeSyncStarredAlbumsCard.setVisibility(View.VISIBLE);
String message = getResources().getQuantityString(
R.plurals.home_sync_starred_albums_count,
albumsNeedingSync.size(),
albumsNeedingSync.size()
);
bind.homeSyncStarredAlbumsToSync.setText(message);
} else {
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
}
}
}
});
}
private void initSyncStarredArtistsView() {
if (Preferences.isStarredArtistsSyncEnabled()) {
homeViewModel.getStarredArtists(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer<List<ArtistID3>>() {
@Override
public void onChanged(List<ArtistID3> artists) {
if (artists != null && !artists.isEmpty()) {
checkIfArtistsNeedSync(artists);
}
}
});
}
bind.homeSyncStarredArtistsCancel.setOnClickListener(v -> {
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
});
bind.homeSyncStarredArtistsDownload.setOnClickListener(v -> {
homeViewModel.getAllStarredArtistSongs().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> allSongs) {
if (allSongs != null && !allSongs.isEmpty()) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
int songsToDownload = 0;
for (Child song : allSongs) {
if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song));
songsToDownload++;
}
}
if (songsToDownload > 0) {
Toast.makeText(requireContext(),
getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
Toast.LENGTH_SHORT).show();
}
}
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
}
});
});
}
private void checkIfArtistsNeedSync(List<ArtistID3> artists) {
homeViewModel.getAllStarredArtistSongs().observe(getViewLifecycleOwner(), new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> allSongs) {
if (allSongs != null) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
int songsToDownload = 0;
List<String> artistsNeedingSync = new ArrayList<>();
for (ArtistID3 artist : artists) {
boolean artistNeedsSync = false;
// Check if any songs from this artist need downloading
for (Child song : allSongs) {
if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) &&
!manager.isDownloaded(song.getId())) {
songsToDownload++;
artistNeedsSync = true;
}
}
if (artistNeedsSync) {
artistsNeedingSync.add(artist.getName());
}
}
if (songsToDownload > 0) {
bind.homeSyncStarredArtistsCard.setVisibility(View.VISIBLE);
String message = getResources().getQuantityString(
R.plurals.home_sync_starred_artists_count,
artistsNeedingSync.size(),
artistsNeedingSync.size()
);
bind.homeSyncStarredArtistsToSync.setText(message);
} else {
bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
}
}
}
});
}
private void initDiscoverSongSlideView() {
if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_DISCOVERY)) return;
@@ -416,8 +600,10 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.topSongsRecyclerView.setHasFixedSize(true);
topSongAdapter = new SongHorizontalAdapter(this, true, false, null);
topSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.topSongsRecyclerView.setAdapter(topSongAdapter);
setTopSongsMediaBrowserListenableFuture();
reapplyTopSongsPlayback();
homeViewModel.getChronologySample(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), chronologies -> {
if (chronologies == null || chronologies.isEmpty()) {
if (bind != null) bind.homeGridTracksSector.setVisibility(View.GONE);
@@ -433,6 +619,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
.collect(Collectors.toList());
topSongAdapter.setItems(topSongs);
reapplyTopSongsPlayback();
}
});
@@ -454,8 +641,10 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.starredTracksRecyclerView.setHasFixedSize(true);
starredSongAdapter = new SongHorizontalAdapter(this, true, false, null);
starredSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.starredTracksRecyclerView.setAdapter(starredSongAdapter);
setStarredSongsMediaBrowserListenableFuture();
reapplyStarredSongsPlayback();
homeViewModel.getStarredTracks(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), songs -> {
if (songs == null) {
if (bind != null) bind.starredTracksSector.setVisibility(View.GONE);
@@ -466,6 +655,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.starredTracksRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), UIUtil.getSpanCount(songs.size(), 5), GridLayoutManager.HORIZONTAL, false));
starredSongAdapter.setItems(songs);
reapplyStarredSongsPlayback();
}
});
@@ -895,6 +1085,8 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION));
activity.setBottomSheetInPeek(true);
}
topSongAdapter.notifyDataSetChanged();
starredSongAdapter.notifyDataSetChanged();
}
@Override
@@ -984,4 +1176,58 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
public void onShareLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.shareBottomSheetDialog, bundle);
}
private void observeStarredSongsPlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (starredSongAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
starredSongAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (starredSongAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
starredSongAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void observeTopSongsPlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (topSongAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
topSongAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (topSongAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
topSongAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyStarredSongsPlayback() {
if (starredSongAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
starredSongAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void reapplyTopSongsPlayback() {
if (topSongAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
topSongAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void setTopSongsMediaBrowserListenableFuture() {
topSongAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
private void setStarredSongsMediaBrowserListenableFuture() {
starredSongAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
}

View File

@@ -174,7 +174,12 @@ public class PlayerBottomSheetFragment extends Fragment {
playerBottomSheetViewModel.setLiveDescription(mediaMetadata.extras.getString("description", null));
bind.playerHeaderLayout.playerHeaderMediaTitleLabel.setText(mediaMetadata.extras.getString("title"));
bind.playerHeaderLayout.playerHeaderMediaArtistLabel.setText(mediaMetadata.extras.getString("artist"));
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))
: "");
CustomGlideRequest.Builder
.from(requireContext(), mediaMetadata.extras.getString("coverArtId"), CustomGlideRequest.ResourceType.Song)
@@ -182,7 +187,11 @@ public class PlayerBottomSheetFragment extends Fragment {
.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"), "") ? 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

@@ -1,15 +1,21 @@
package com.cappielloantonio.tempo.ui.fragment;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.IBinder;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.ToggleButton;
import android.widget.RatingBar;
import androidx.annotation.NonNull;
import androidx.constraintlayout.widget.ConstraintLayout;
@@ -22,11 +28,14 @@ import androidx.media3.common.util.RepeatModeUtil;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.media3.session.SessionToken;
import androidx.navigation.NavController;
import androidx.navigation.NavOptions;
import androidx.navigation.fragment.NavHostFragment;
import androidx.viewpager2.widget.ViewPager2;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerControllerBinding;
import com.cappielloantonio.tempo.service.EqualizerManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
@@ -36,6 +45,7 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.cappielloantonio.tempo.viewmodel.RatingViewModel;
import com.google.android.material.chip.Chip;
import com.google.android.material.elevation.SurfaceColors;
import com.google.common.util.concurrent.ListenableFuture;
@@ -53,6 +63,8 @@ public class PlayerControllerFragment extends Fragment {
private InnerFragmentPlayerControllerBinding bind;
private ViewPager2 playerMediaCoverViewPager;
private ToggleButton buttonFavorite;
private RatingViewModel ratingViewModel;
private RatingBar songRatingBar;
private TextView playerMediaTitleLabel;
private TextView playerArtistNameLabel;
private Button playbackSpeedButton;
@@ -62,11 +74,16 @@ public class PlayerControllerFragment extends Fragment {
private ConstraintLayout playerQuickActionView;
private ImageButton playerOpenQueueButton;
private ImageButton playerTrackInfo;
private LinearLayout ratingContainer;
private ImageButton equalizerButton;
private MainActivity activity;
private PlayerBottomSheetViewModel playerBottomSheetViewModel;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private MediaService.LocalBinder mediaServiceBinder;
private boolean isServiceBound = false;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
activity = (MainActivity) getActivity();
@@ -75,6 +92,7 @@ public class PlayerControllerFragment extends Fragment {
View view = bind.getRoot();
playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
ratingViewModel = new ViewModelProvider(requireActivity()).get(RatingViewModel.class);
init();
initQuickActionView();
@@ -82,6 +100,7 @@ public class PlayerControllerFragment extends Fragment {
initMediaListenable();
initMediaLabelButton();
initArtistLabelButton();
initEqualizerButton();
return view;
}
@@ -117,6 +136,10 @@ public class PlayerControllerFragment extends Fragment {
playerQuickActionView = bind.getRoot().findViewById(R.id.player_quick_action_view);
playerOpenQueueButton = bind.getRoot().findViewById(R.id.player_open_queue_button);
playerTrackInfo = bind.getRoot().findViewById(R.id.player_info_track);
songRatingBar = bind.getRoot().findViewById(R.id.song_rating_bar);
ratingContainer = bind.getRoot().findViewById(R.id.rating_container);
equalizerButton = bind.getRoot().findViewById(R.id.player_open_equalizer_button);
checkAndSetRatingContainerVisibility();
}
private void initQuickActionView() {
@@ -146,7 +169,6 @@ public class PlayerControllerFragment extends Fragment {
bind.nowPlayingMediaControllerView.setPlayer(mediaBrowser);
mediaBrowser.setShuffleModeEnabled(Preferences.isShuffleModeEnabled());
mediaBrowser.setRepeatMode(Preferences.getRepeatMode());
setMediaControllerListener(mediaBrowser);
} catch (Exception e) {
e.printStackTrace();
@@ -181,18 +203,27 @@ public class PlayerControllerFragment extends Fragment {
private void setMetadata(MediaMetadata mediaMetadata) {
playerMediaTitleLabel.setText(String.valueOf(mediaMetadata.title));
playerArtistNameLabel.setText(String.valueOf(mediaMetadata.artist));
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);
playerArtistNameLabel.setSelected(true);
playerMediaTitleLabel.setVisibility(mediaMetadata.title != null && !Objects.equals(mediaMetadata.title, "") ? View.VISIBLE : View.GONE);
playerArtistNameLabel.setVisibility(mediaMetadata.artist != null && !Objects.equals(mediaMetadata.artist, "") ? View.VISIBLE : View.GONE);
playerArtistNameLabel.setVisibility(
(mediaMetadata.artist != null && !Objects.equals(mediaMetadata.artist, ""))
|| mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) && mediaMetadata.extras.getString("uri") != null
? View.VISIBLE
: View.GONE);
}
private void setMediaInfo(MediaMetadata mediaMetadata) {
if (mediaMetadata.extras != null) {
String extension = mediaMetadata.extras.getString("suffix", "Unknown format");
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" : "";
String bitDepth = mediaMetadata.extras.getInt("bitDepth", 0) != 0 ? mediaMetadata.extras.getInt("bitDepth", 0) + "b" : "";
@@ -218,8 +249,8 @@ public class PlayerControllerFragment extends Fragment {
boolean isTranscodingBitrate = !MusicUtil.getBitratePreference().equals("0");
if (isTranscodingExtension || isTranscodingBitrate) {
playerMediaExtension.setText("Transcoding");
playerMediaBitrate.setText("requested");
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 -> {
@@ -305,6 +336,7 @@ public class PlayerControllerFragment extends Fragment {
private void initMediaListenable() {
playerBottomSheetViewModel.getLiveMedia().observe(getViewLifecycleOwner(), media -> {
if (media != null) {
ratingViewModel.setSong(media);
buttonFavorite.setChecked(media.getStarred() != null);
buttonFavorite.setOnClickListener(v -> playerBottomSheetViewModel.setFavorite(requireContext(), media));
buttonFavorite.setOnLongClickListener(v -> {
@@ -315,9 +347,29 @@ public class PlayerControllerFragment extends Fragment {
dialog.setArguments(bundle);
dialog.show(requireActivity().getSupportFragmentManager(), null);
return true;
});
Integer currentRating = media.getUserRating();
if (currentRating != null) {
songRatingBar.setRating(currentRating);
} else {
songRatingBar.setRating(0);
}
songRatingBar.setOnRatingBarChangeListener(new RatingBar.OnRatingBarChangeListener() {
@Override
public void onRatingChanged(RatingBar ratingBar, float rating, boolean fromUser) {
if (fromUser) {
ratingViewModel.rate((int) rating);
media.setUserRating((int) rating);
}
}
});
if (getActivity() != null) {
playerBottomSheetViewModel.refreshMediaInfo(requireActivity(), media);
}
@@ -387,6 +439,18 @@ public class PlayerControllerFragment extends Fragment {
});
}
private void initEqualizerButton() {
equalizerButton.setOnClickListener(v -> {
NavController navController = NavHostFragment.findNavController(this);
NavOptions navOptions = new NavOptions.Builder()
.setLaunchSingleTop(true)
.setPopUpTo(R.id.equalizerFragment, true)
.build();
navController.navigate(R.id.equalizerFragment, null, navOptions);
if (activity != null) activity.collapseBottomSheetDelayed();
});
}
public void goToControllerPage() {
playerMediaCoverViewPager.setCurrentItem(0, false);
}
@@ -395,6 +459,17 @@ public class PlayerControllerFragment extends Fragment {
playerMediaCoverViewPager.setCurrentItem(1, true);
}
private void checkAndSetRatingContainerVisibility() {
if (ratingContainer == null) return;
if (Preferences.showItemStarRating()) {
ratingContainer.setVisibility(View.VISIBLE);
}
else {
ratingContainer.setVisibility(View.GONE);
}
}
private void setPlaybackParameters(MediaBrowser mediaBrowser) {
Button playbackSpeedButton = bind.getRoot().findViewById(R.id.player_playback_speed_button);
float currentSpeed = Preferences.getPlaybackSpeed();
@@ -411,4 +486,66 @@ public class PlayerControllerFragment extends Fragment {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_100));
// TODO Resettare lo skip del silenzio
}
private final ServiceConnection serviceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
mediaServiceBinder = (MediaService.LocalBinder) service;
isServiceBound = true;
checkEqualizerBands();
}
@Override
public void onServiceDisconnected(ComponentName name) {
mediaServiceBinder = null;
isServiceBound = false;
}
};
private void bindMediaService() {
Intent intent = new Intent(requireActivity(), MediaService.class);
intent.setAction(MediaService.ACTION_BIND_EQUALIZER);
requireActivity().bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
isServiceBound = true;
}
private void checkEqualizerBands() {
if (mediaServiceBinder != null) {
EqualizerManager eqManager = mediaServiceBinder.getEqualizerManager();
short numBands = eqManager.getNumberOfBands();
if (equalizerButton != null) {
if (numBands == 0) {
equalizerButton.setVisibility(View.GONE);
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) playerOpenQueueButton.getLayoutParams();
params.startToEnd = ConstraintLayout.LayoutParams.UNSET;
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
playerOpenQueueButton.setLayoutParams(params);
} else {
equalizerButton.setVisibility(View.VISIBLE);
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) playerOpenQueueButton.getLayoutParams();
params.startToStart = ConstraintLayout.LayoutParams.UNSET;
params.startToEnd = R.id.player_open_equalizer_button;
playerOpenQueueButton.setLayoutParams(params);
}
}
}
}
@Override
public void onResume() {
super.onResume();
bindMediaService();
}
@Override
public void onPause() {
super.onPause();
if (isServiceBound) {
requireActivity().unbindService(serviceConnection);
isServiceBound = false;
}
}
}

View File

@@ -9,16 +9,19 @@ import android.transition.TransitionManager;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import java.util.ArrayList;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.media3.session.SessionToken;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerCoverBinding;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.model.Download;
@@ -29,7 +32,9 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.google.android.material.snackbar.Snackbar;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
@@ -112,15 +117,21 @@ public class PlayerCoverFragment extends Fragment {
playerBottomSheetViewModel.getLiveMedia().observe(getViewLifecycleOwner(), song -> {
if (song != null && bind != null) {
bind.innerButtonTopLeft.setOnClickListener(view -> {
DownloadUtil.getDownloadTracker(requireContext()).download(
MappingUtil.mapDownload(song),
new Download(song)
);
if (Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(requireContext()).download(
MappingUtil.mapDownload(song),
new Download(song)
);
} else {
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
}
});
bind.innerButtonTopRight.setOnClickListener(view -> {
ArrayList<Child> tracks = new ArrayList<>();
tracks.add(song);
Bundle bundle = new Bundle();
bundle.putParcelable(Constants.TRACK_OBJECT, song);
bundle.putParcelableArrayList(Constants.TRACKS_OBJECT, tracks);
PlaylistChooserDialog dialog = new PlaylistChooserDialog();
dialog.setArguments(bundle);
@@ -136,7 +147,7 @@ public class PlayerCoverFragment extends Fragment {
bind.innerButtonBottomRight.setOnClickListener(view -> {
if (playerBottomSheetViewModel.savePlayQueue()) {
Snackbar.make(requireView(), "Salvato", Snackbar.LENGTH_LONG).show();
Snackbar.make(requireView(), R.string.player_queue_save_queue_success, Snackbar.LENGTH_LONG).show();
}
});

View File

@@ -4,15 +4,16 @@ import android.annotation.SuppressLint;
import android.content.ComponentName;
import android.os.Bundle;
import android.os.Handler;
import android.text.Layout;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Layout;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -29,10 +30,10 @@ import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Line;
import com.cappielloantonio.tempo.subsonic.models.LyricsList;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.OpenSubsonicExtensionsUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.android.material.button.MaterialButton;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.List;
@@ -48,6 +49,9 @@ public class PlayerLyricsFragment extends Fragment {
private MediaBrowser mediaBrowser;
private Handler syncLyricsHandler;
private Runnable syncLyricsRunnable;
private String currentLyrics;
private LyricsList currentLyricsList;
private String currentDescription;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
@@ -66,6 +70,7 @@ public class PlayerLyricsFragment extends Fragment {
super.onViewCreated(view, savedInstanceState);
initPanelContent();
observeDownloadState();
}
@Override
@@ -101,12 +106,26 @@ public class PlayerLyricsFragment extends Fragment {
public void onDestroyView() {
super.onDestroyView();
bind = null;
currentLyrics = null;
currentLyricsList = null;
currentDescription = null;
}
private void initOverlay() {
bind.syncLyricsTapButton.setOnClickListener(view -> {
playerBottomSheetViewModel.changeSyncLyricsState();
});
bind.downloadLyricsButton.setOnClickListener(view -> {
boolean saved = playerBottomSheetViewModel.downloadCurrentLyrics();
if (getContext() != null) {
Toast.makeText(
requireContext(),
saved ? R.string.player_lyrics_download_success : R.string.player_lyrics_download_failure,
Toast.LENGTH_SHORT
).show();
}
});
}
private void initializeBrowser() {
@@ -136,50 +155,91 @@ public class PlayerLyricsFragment extends Fragment {
}
private void initPanelContent() {
if (OpenSubsonicExtensionsUtil.isSongLyricsExtensionAvailable()) {
playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
setPanelContent(null, lyricsList);
});
} else {
playerBottomSheetViewModel.getLiveLyrics().observe(getViewLifecycleOwner(), lyrics -> {
setPanelContent(lyrics, null);
});
}
playerBottomSheetViewModel.getLiveLyrics().observe(getViewLifecycleOwner(), lyrics -> {
currentLyrics = lyrics;
updatePanelContent();
});
playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
currentLyricsList = lyricsList;
updatePanelContent();
});
playerBottomSheetViewModel.getLiveDescription().observe(getViewLifecycleOwner(), description -> {
currentDescription = description;
updatePanelContent();
});
}
private void setPanelContent(String lyrics, LyricsList lyricsList) {
playerBottomSheetViewModel.getLiveDescription().observe(getViewLifecycleOwner(), description -> {
private void observeDownloadState() {
playerBottomSheetViewModel.getLyricsCachedState().observe(getViewLifecycleOwner(), cached -> {
if (bind != null) {
bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0);
if (lyrics != null && !lyrics.trim().equals("")) {
bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(lyrics));
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
bind.emptyDescriptionImageView.setVisibility(View.GONE);
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
bind.syncLyricsTapButton.setVisibility(View.GONE);
} else if (lyricsList != null && lyricsList.getStructuredLyrics() != null) {
setSyncLirics(lyricsList);
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
bind.emptyDescriptionImageView.setVisibility(View.GONE);
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
bind.syncLyricsTapButton.setVisibility(View.VISIBLE);
} else if (description != null && !description.trim().equals("")) {
bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(description));
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
bind.emptyDescriptionImageView.setVisibility(View.GONE);
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
bind.syncLyricsTapButton.setVisibility(View.GONE);
MaterialButton downloadButton = (MaterialButton) bind.downloadLyricsButton;
if (cached != null && cached) {
downloadButton.setIconResource(R.drawable.ic_done);
downloadButton.setContentDescription(getString(R.string.player_lyrics_downloaded_content_description));
} else {
bind.nowPlayingSongLyricsTextView.setVisibility(View.GONE);
bind.emptyDescriptionImageView.setVisibility(View.VISIBLE);
bind.titleEmptyDescriptionLabel.setVisibility(View.VISIBLE);
bind.syncLyricsTapButton.setVisibility(View.GONE);
downloadButton.setIconResource(R.drawable.ic_download);
downloadButton.setContentDescription(getString(R.string.player_lyrics_download_content_description));
}
}
});
}
private void updatePanelContent() {
if (bind == null) {
return;
}
bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0);
if (hasStructuredLyrics(currentLyricsList)) {
setSyncLirics(currentLyricsList);
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
bind.emptyDescriptionImageView.setVisibility(View.GONE);
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
bind.syncLyricsTapButton.setVisibility(View.VISIBLE);
bind.downloadLyricsButton.setVisibility(View.VISIBLE);
bind.downloadLyricsButton.setEnabled(true);
} else if (hasText(currentLyrics)) {
bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(currentLyrics));
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
bind.emptyDescriptionImageView.setVisibility(View.GONE);
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
bind.syncLyricsTapButton.setVisibility(View.GONE);
bind.downloadLyricsButton.setVisibility(View.VISIBLE);
bind.downloadLyricsButton.setEnabled(true);
} else if (hasText(currentDescription)) {
bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(currentDescription));
bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
bind.emptyDescriptionImageView.setVisibility(View.GONE);
bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
bind.syncLyricsTapButton.setVisibility(View.GONE);
bind.downloadLyricsButton.setVisibility(View.GONE);
bind.downloadLyricsButton.setEnabled(false);
} else {
bind.nowPlayingSongLyricsTextView.setVisibility(View.GONE);
bind.emptyDescriptionImageView.setVisibility(View.VISIBLE);
bind.titleEmptyDescriptionLabel.setVisibility(View.VISIBLE);
bind.syncLyricsTapButton.setVisibility(View.GONE);
bind.downloadLyricsButton.setVisibility(View.GONE);
bind.downloadLyricsButton.setEnabled(false);
}
}
private boolean hasText(String value) {
return value != null && !value.trim().isEmpty();
}
private boolean hasStructuredLyrics(LyricsList lyricsList) {
return lyricsList != null
&& lyricsList.getStructuredLyrics() != null
&& !lyricsList.getStructuredLyrics().isEmpty()
&& lyricsList.getStructuredLyrics().get(0) != null
&& lyricsList.getStructuredLyrics().get(0).getLine() != null
&& !lyricsList.getStructuredLyrics().get(0).getLine().isEmpty();
}
@SuppressLint("DefaultLocale")
private void setSyncLirics(LyricsList lyricsList) {
if (lyricsList.getStructuredLyrics() != null && !lyricsList.getStructuredLyrics().isEmpty() && lyricsList.getStructuredLyrics().get(0).getLine() != null) {
@@ -198,28 +258,28 @@ public class PlayerLyricsFragment extends Fragment {
private void defineProgressHandler() {
playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
if (lyricsList != null) {
if (lyricsList.getStructuredLyrics() != null && lyricsList.getStructuredLyrics().get(0) != null && !lyricsList.getStructuredLyrics().get(0).getSynced()) {
releaseHandler();
return;
}
syncLyricsHandler = new Handler();
syncLyricsRunnable = () -> {
if (syncLyricsHandler != null) {
if (bind != null) {
displaySyncedLyrics();
}
syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
}
};
syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
} else {
if (!hasStructuredLyrics(lyricsList)) {
releaseHandler();
return;
}
if (!lyricsList.getStructuredLyrics().get(0).getSynced()) {
releaseHandler();
return;
}
syncLyricsHandler = new Handler();
syncLyricsRunnable = () -> {
if (syncLyricsHandler != null) {
if (bind != null) {
displaySyncedLyrics();
}
syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
}
};
syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
});
}
@@ -227,7 +287,7 @@ public class PlayerLyricsFragment extends Fragment {
LyricsList lyricsList = playerBottomSheetViewModel.getLiveLyricsList().getValue();
int timestamp = (int) (mediaBrowser.getCurrentPosition());
if (lyricsList != null && lyricsList.getStructuredLyrics() != null && !lyricsList.getStructuredLyrics().isEmpty() && lyricsList.getStructuredLyrics().get(0).getLine() != null) {
if (hasStructuredLyrics(lyricsList)) {
StringBuilder lyricsBuilder = new StringBuilder();
List<Line> lines = lyricsList.getStructuredLyrics().get(0).getLine();

View File

@@ -23,6 +23,7 @@ import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.adapter.PlayerSongQueueAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
@@ -38,6 +39,7 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
private InnerFragmentPlayerQueueBinding bind;
private PlayerBottomSheetViewModel playerBottomSheetViewModel;
private PlaybackViewModel playbackViewModel;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private PlayerSongQueueAdapter playerSongQueueAdapter;
@@ -48,6 +50,7 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
View view = bind.getRoot();
playerBottomSheetViewModel = new ViewModelProvider(requireActivity()).get(PlayerBottomSheetViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
initQueueRecyclerView();
@@ -59,6 +62,9 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
super.onStart();
initializeBrowser();
bindMediaController();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
@Override
@@ -110,9 +116,12 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
playerSongQueueAdapter = new PlayerSongQueueAdapter(this);
bind.playerQueueRecyclerView.setAdapter(playerSongQueueAdapter);
reapplyPlayback();
playerBottomSheetViewModel.getQueueSong().observe(getViewLifecycleOwner(), queue -> {
if (queue != null) {
playerSongQueueAdapter.setItems(queue.stream().map(item -> (Child) item).collect(Collectors.toList()));
reapplyPlayback();
}
});
@@ -216,4 +225,27 @@ public class PlayerQueueFragment extends Fragment implements ClickCallback {
public void onMediaClick(Bundle bundle) {
MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION));
}
private void observePlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (playerSongQueueAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (playerSongQueueAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyPlayback() {
if (playerSongQueueAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
playerSongQueueAdapter.setPlaybackState(id, playing != null && playing);
}
}
}

View File

@@ -37,6 +37,9 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaylistPageViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@@ -49,6 +52,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
private FragmentPlaylistPageBinding bind;
private MainActivity activity;
private PlaylistPageViewModel playlistPageViewModel;
private PlaybackViewModel playbackViewModel;
private SongHorizontalAdapter songHorizontalAdapter;
@@ -94,6 +98,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
bind = FragmentPlaylistPageBinding.inflate(inflater, container, false);
View view = bind.getRoot();
playlistPageViewModel = new ViewModelProvider(requireActivity()).get(PlaylistPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
initAppBar();
@@ -109,6 +114,15 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
super.onStart();
initializeMediaBrowser();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
@Override
public void onResume() {
super.onResume();
if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
}
@Override
@@ -128,7 +142,8 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
if (item.getItemId() == R.id.action_download_playlist) {
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> {
if (isVisible() && getActivity() != null) {
DownloadUtil.getDownloadTracker(requireContext()).download(
if (Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(requireContext()).download(
MappingUtil.mapDownloads(songs),
songs.stream().map(child -> {
Download toDownload = new Download(child);
@@ -136,7 +151,10 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
toDownload.setPlaylistName(playlistPageViewModel.getPlaylist().getName());
return toDownload;
}).collect(Collectors.toList())
);
);
} else {
songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
}
}
});
return true;
@@ -246,10 +264,15 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.songRecyclerView.setHasFixedSize(true);
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture();
reapplyPlayback();
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> songHorizontalAdapter.setItems(songs));
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> {
songHorizontalAdapter.setItems(songs);
reapplyPlayback();
});
}
private void initializeMediaBrowser() {
@@ -270,4 +293,31 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
public void onMediaLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
}
private void observePlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (songHorizontalAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyPlayback() {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void setMediaBrowserListenableFuture() {
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
}

View File

@@ -4,14 +4,11 @@ import android.content.ComponentName;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -34,6 +31,7 @@ import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter;
import com.cappielloantonio.tempo.ui.adapter.ArtistAdapter;
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.SearchViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@@ -46,6 +44,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
private FragmentSearchBinding bind;
private MainActivity activity;
private SearchViewModel searchViewModel;
private PlaybackViewModel playbackViewModel;
private ArtistAdapter artistAdapter;
private AlbumAdapter albumAdapter;
@@ -61,6 +60,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
bind = FragmentSearchBinding.inflate(inflater, container, false);
View view = bind.getRoot();
searchViewModel = new ViewModelProvider(requireActivity()).get(SearchViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
initSearchResultView();
initSearchView();
@@ -73,6 +73,15 @@ public class SearchFragment extends Fragment implements ClickCallback {
public void onStart() {
super.onStart();
initializeMediaBrowser();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
@Override
public void onResume() {
super.onResume();
if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
}
@Override
@@ -112,7 +121,10 @@ public class SearchFragment extends Fragment implements ClickCallback {
bind.searchResultTracksRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.searchResultTracksRecyclerView.setHasFixedSize(true);
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
setMediaBrowserListenableFuture();
reapplyPlayback();
bind.searchResultTracksRecyclerView.setAdapter(songHorizontalAdapter);
}
@@ -242,7 +254,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
}
private boolean isQueryValid(String query) {
return !query.equals("") && query.trim().length() > 2;
return !query.equals("") && query.trim().length() > 1;
}
private void inputFocus() {
@@ -260,6 +272,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
@Override
public void onMediaClick(Bundle bundle) {
MediaManager.startQueue(mediaBrowserListenableFuture, bundle.getParcelableArrayList(Constants.TRACKS_OBJECT), bundle.getInt(Constants.ITEM_POSITION));
songHorizontalAdapter.notifyDataSetChanged();
activity.setBottomSheetInPeek(true);
}
@@ -287,4 +300,31 @@ public class SearchFragment extends Fragment implements ClickCallback {
public void onArtistLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.artistBottomSheetDialog, bundle);
}
private void observePlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (songHorizontalAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyPlayback() {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void setMediaBrowserListenableFuture() {
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
}

View File

@@ -1,13 +1,19 @@
package com.cappielloantonio.tempo.ui.fragment;
import android.app.Activity;
import android.content.Context;
import android.content.ComponentName;
import android.content.Intent;
import android.content.ServiceConnection;
import android.media.audiofx.AudioEffect;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
@@ -18,23 +24,32 @@ import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.os.LocaleListCompat;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController;
import androidx.navigation.NavOptions;
import androidx.navigation.fragment.NavHostFragment;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
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.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;
@@ -47,15 +62,41 @@ public class SettingsFragment extends PreferenceFragmentCompat {
private MainActivity activity;
private SettingViewModel settingViewModel;
private ActivityResultLauncher<Intent> someActivityResultLauncher;
private ActivityResultLauncher<Intent> equalizerResultLauncher;
private ActivityResultLauncher<Intent> directoryPickerLauncher;
private MediaService.LocalBinder mediaServiceBinder;
private boolean isServiceBound = false;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
someActivityResultLauncher = registerForActivityResult(
equalizerResultLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {}
);
directoryPickerLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == Activity.RESULT_OK) {
Intent data = result.getData();
if (data != null) {
Uri uri = data.getData();
if (uri != null) {
requireContext().getContentResolver().takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
);
Preferences.setDownloadDirectoryUri(uri.toString());
ExternalAudioReader.refreshCache();
Toast.makeText(requireContext(), "Download folder set.", Toast.LENGTH_SHORT).show();
checkDownloadDirectory();
}
}
}
});
}
@@ -84,9 +125,10 @@ public class SettingsFragment extends PreferenceFragmentCompat {
public void onResume() {
super.onResume();
checkEqualizer();
checkSystemEqualizer();
checkCacheStorage();
checkStorage();
checkDownloadDirectory();
setStreamingCacheSize();
setAppLanguage();
@@ -94,11 +136,19 @@ public class SettingsFragment extends PreferenceFragmentCompat {
actionLogout();
actionScan();
actionSyncStarredAlbums();
actionSyncStarredTracks();
actionSyncStarredArtists();
actionChangeStreamingCacheStorage();
actionChangeDownloadStorage();
actionSetDownloadDirectory();
actionDeleteDownloadStorage();
actionKeepScreenOn();
actionAutoDownloadLyrics();
actionMiniPlayerHeart();
bindMediaService();
actionAppEqualizer();
}
@Override
@@ -121,8 +171,8 @@ public class SettingsFragment extends PreferenceFragmentCompat {
}
}
private void checkEqualizer() {
Preference equalizer = findPreference("equalizer");
private void checkSystemEqualizer() {
Preference equalizer = findPreference("system_equalizer");
if (equalizer == null) return;
@@ -130,7 +180,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
if ((intent.resolveActivity(requireActivity().getPackageManager()) != null)) {
equalizer.setOnPreferenceClickListener(preference -> {
someActivityResultLauncher.launch(intent);
equalizerResultLauncher.launch(intent);
return true;
});
} else {
@@ -147,7 +197,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
if (requireContext().getExternalFilesDirs(null)[1] == null) {
storage.setVisible(false);
} else {
storage.setSummary(Preferences.getDownloadStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
storage.setSummary(Preferences.getStreamingCacheStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
}
} catch (Exception exception) {
storage.setVisible(false);
@@ -163,13 +213,46 @@ public class SettingsFragment extends PreferenceFragmentCompat {
if (requireContext().getExternalFilesDirs(null)[1] == null) {
storage.setVisible(false);
} else {
storage.setSummary(Preferences.getDownloadStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
int pref = Preferences.getDownloadStoragePreference();
if (pref == 0) {
storage.setSummary(R.string.download_storage_internal_dialog_negative_button);
} else if (pref == 1) {
storage.setSummary(R.string.download_storage_external_dialog_positive_button);
} else {
storage.setSummary(R.string.download_storage_directory_dialog_neutral_button);
}
}
} catch (Exception exception) {
storage.setVisible(false);
}
}
private void checkDownloadDirectory() {
Preference storage = findPreference("download_storage");
Preference directory = findPreference("set_download_directory");
if (directory == null) return;
String current = Preferences.getDownloadDirectoryUri();
if (current != null) {
if (storage != null) storage.setVisible(false);
directory.setVisible(true);
directory.setIcon(R.drawable.ic_close);
directory.setTitle("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("Set download folder");
directory.setSummary("Choose a folder for downloaded music files");
} else {
directory.setVisible(false);
}
}
}
private void setStreamingCacheSize() {
ListPreference streamingCachePreference = findPreference("streaming_cache_size");
@@ -255,7 +338,37 @@ public class SettingsFragment extends PreferenceFragmentCompat {
findPreference("sync_starred_tracks_for_offline_use").setOnPreferenceChangeListener((preference, newValue) -> {
if (newValue instanceof Boolean) {
if ((Boolean) newValue) {
StarredSyncDialog dialog = new StarredSyncDialog();
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);
}
}
@@ -287,11 +400,19 @@ public class SettingsFragment extends PreferenceFragmentCompat {
@Override
public void onPositiveClick() {
findPreference("download_storage").setSummary(R.string.download_storage_external_dialog_positive_button);
checkDownloadDirectory();
}
@Override
public void onNegativeClick() {
findPreference("download_storage").setSummary(R.string.download_storage_internal_dialog_negative_button);
checkDownloadDirectory();
}
@Override
public void onNeutralClick() {
findPreference("download_storage").setSummary(R.string.download_storage_directory_dialog_neutral_button);
checkDownloadDirectory();
}
});
dialog.show(activity.getSupportFragmentManager(), null);
@@ -299,6 +420,31 @@ public class SettingsFragment extends PreferenceFragmentCompat {
});
}
private void actionSetDownloadDirectory() {
Preference pref = findPreference("set_download_directory");
if (pref != null) {
pref.setOnPreferenceClickListener(preference -> {
String current = Preferences.getDownloadDirectoryUri();
if (current != null) {
Preferences.setDownloadDirectoryUri(null);
Preferences.setDownloadStoragePreference(0);
ExternalAudioReader.refreshCache();
Toast.makeText(requireContext(), "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();
@@ -307,6 +453,36 @@ public class SettingsFragment extends PreferenceFragmentCompat {
});
}
private void actionMiniPlayerHeart() {
SwitchPreference preference = findPreference("mini_shuffle_button_visibility");
if (preference == null) {
return;
}
preference.setChecked(Preferences.showShuffleInsteadOfHeart());
preference.setOnPreferenceChangeListener((pref, newValue) -> {
if (newValue instanceof Boolean) {
Preferences.setShuffleInsteadOfHeart((Boolean) newValue);
}
return true;
});
}
private void actionAutoDownloadLyrics() {
SwitchPreference preference = findPreference("auto_download_lyrics");
if (preference == null) {
return;
}
preference.setChecked(Preferences.isAutoDownloadLyricsEnabled());
preference.setOnPreferenceChangeListener((pref, newValue) -> {
if (newValue instanceof Boolean) {
Preferences.setAutoDownloadLyricsEnabled((Boolean) newValue);
}
return true;
});
}
private void getScanStatus() {
settingViewModel.getScanStatus(new ScanCallback() {
@Override
@@ -334,4 +510,63 @@ public class SettingsFragment extends PreferenceFragmentCompat {
return true;
});
}
private final ServiceConnection serviceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
mediaServiceBinder = (MediaService.LocalBinder) service;
isServiceBound = true;
checkEqualizerBands();
}
@Override
public void onServiceDisconnected(ComponentName name) {
mediaServiceBinder = null;
isServiceBound = false;
}
};
private void bindMediaService() {
Intent intent = new Intent(requireActivity(), MediaService.class);
intent.setAction(MediaService.ACTION_BIND_EQUALIZER);
requireActivity().bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
isServiceBound = true;
}
private void checkEqualizerBands() {
if (mediaServiceBinder != null) {
EqualizerManager eqManager = mediaServiceBinder.getEqualizerManager();
short numBands = eqManager.getNumberOfBands();
Preference appEqualizer = findPreference("app_equalizer");
if (appEqualizer != null) {
appEqualizer.setVisible(numBands > 0);
}
}
}
private void actionAppEqualizer() {
Preference appEqualizer = findPreference("app_equalizer");
if (appEqualizer != null) {
appEqualizer.setOnPreferenceClickListener(preference -> {
NavController navController = NavHostFragment.findNavController(this);
NavOptions navOptions = new NavOptions.Builder()
.setLaunchSingleTop(true)
.setPopUpTo(R.id.equalizerFragment, true)
.build();
activity.setBottomNavigationBarVisibility(true);
activity.setBottomSheetVisibility(true);
navController.navigate(R.id.equalizerFragment, null, navOptions);
return true;
});
}
}
@Override
public void onPause() {
super.onPause();
if (isServiceBound) {
requireActivity().unbindService(serviceConnection);
isServiceBound = false;
}
}
}

View File

@@ -36,6 +36,7 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.SongListPageViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@@ -49,6 +50,7 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
private FragmentSongListPageBinding bind;
private MainActivity activity;
private SongListPageViewModel songListPageViewModel;
private PlaybackViewModel playbackViewModel;
private SongHorizontalAdapter songHorizontalAdapter;
@@ -69,6 +71,7 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
bind = FragmentSongListPageBinding.inflate(inflater, container, false);
View view = bind.getRoot();
songListPageViewModel = new ViewModelProvider(requireActivity()).get(SongListPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
initAppBar();
@@ -82,6 +85,15 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
public void onStart() {
super.onStart();
initializeMediaBrowser();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
@Override
public void onResume() {
super.onResume();
setMediaBrowserListenableFuture();
}
@Override
@@ -189,11 +201,14 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
bind.songListRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.songListRecyclerView.setHasFixedSize(true);
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.songListRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture();
reapplyPlayback();
songListPageViewModel.getSongList().observe(getViewLifecycleOwner(), songs -> {
isLoading = false;
songHorizontalAdapter.setItems(songs);
reapplyPlayback();
setSongListPageSubtitle(songs);
});
@@ -325,4 +340,31 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
public void onMediaLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
}
private void observePlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (songHorizontalAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyPlayback() {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void setMediaBrowserListenableFuture() {
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
}

View File

@@ -13,6 +13,7 @@ import android.widget.TextView;
import android.widget.Toast;
import android.widget.ToggleButton;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.MediaItem;
@@ -37,6 +38,8 @@ import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.viewmodel.AlbumBottomSheetViewModel;
import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
@@ -54,6 +57,10 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
private AlbumBottomSheetViewModel albumBottomSheetViewModel;
private AlbumID3 album;
private TextView removeAllTextView;
private List<Child> currentAlbumTracks = Collections.emptyList();
private List<MediaItem> currentAlbumMediaItems = Collections.emptyList();
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@Nullable
@@ -72,6 +79,12 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
return view;
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateRemoveAllVisibility);
}
@Override
public void onStart() {
super.onStart();
@@ -102,7 +115,7 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
favoriteToggle.setChecked(albumBottomSheetViewModel.getAlbum().getStarred() != null);
favoriteToggle.setOnClickListener(v -> {
albumBottomSheetViewModel.setFavorite();
albumBottomSheetViewModel.setFavorite(requireContext());
});
TextView playRadio = view.findViewById(R.id.play_radio_text_view);
@@ -163,7 +176,11 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
downloadAll.setOnClickListener(v -> {
DownloadUtil.getDownloadTracker(requireContext()).download(mediaItems, downloads);
if (Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(requireContext()).download(mediaItems, downloads);
} else {
songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
}
dismissBottomSheet();
});
});
@@ -182,19 +199,23 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
});
});
TextView removeAll = view.findViewById(R.id.remove_all_text_view);
removeAllTextView = view.findViewById(R.id.remove_all_text_view);
albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> {
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
currentAlbumTracks = songs != null ? songs : Collections.emptyList();
currentAlbumMediaItems = MappingUtil.mapDownloads(currentAlbumTracks);
removeAll.setOnClickListener(v -> {
DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
removeAllTextView.setOnClickListener(v -> {
if (Preferences.getDownloadDirectoryUri() == null) {
List<Download> downloads = currentAlbumTracks.stream().map(Download::new).collect(Collectors.toList());
DownloadUtil.getDownloadTracker(requireContext()).remove(currentAlbumMediaItems, downloads);
} else {
currentAlbumTracks.forEach(ExternalAudioReader::delete);
}
dismissBottomSheet();
});
updateRemoveAllVisibility();
});
initDownloadUI(removeAll);
TextView goToArtist = view.findViewById(R.id.go_to_artist_text_view);
goToArtist.setOnClickListener(v -> albumBottomSheetViewModel.getArtist().observe(getViewLifecycleOwner(), artist -> {
if (artist != null) {
@@ -234,14 +255,29 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
dismiss();
}
private void initDownloadUI(TextView removeAll) {
albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> {
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
private void updateRemoveAllVisibility() {
if (removeAllTextView == null) {
return;
}
if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) {
removeAll.setVisibility(View.VISIBLE);
if (currentAlbumTracks == null || currentAlbumTracks.isEmpty()) {
removeAllTextView.setVisibility(View.GONE);
return;
}
if (Preferences.getDownloadDirectoryUri() == null) {
List<MediaItem> mediaItems = currentAlbumMediaItems;
if (mediaItems == null || mediaItems.isEmpty()) {
removeAllTextView.setVisibility(View.GONE);
} else if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) {
removeAllTextView.setVisibility(View.VISIBLE);
} else {
removeAllTextView.setVisibility(View.GONE);
}
});
} else {
boolean hasLocal = currentAlbumTracks.stream().anyMatch(song -> ExternalAudioReader.getUri(song) != null);
removeAllTextView.setVisibility(hasLocal ? View.VISIBLE : View.GONE);
}
}
private void initializeMediaBrowser() {

View File

@@ -66,7 +66,7 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement
super.onStop();
}
// TODO Utilizzare il viewmodel come tramite ed evitare le chiamate dirette
// TODO Use the viewmodel as a conduit and avoid direct calls
private void init(View view) {
ImageView coverArtist = view.findViewById(R.id.artist_cover_image_view);
CustomGlideRequest.Builder
@@ -81,7 +81,7 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
favoriteToggle.setChecked(artistBottomSheetViewModel.getArtist().getStarred() != null);
favoriteToggle.setOnClickListener(v -> {
artistBottomSheetViewModel.setFavorite();
artistBottomSheetViewModel.setFavorite(requireContext());
});
TextView playRadio = view.findViewById(R.id.play_radio_text_view);

View File

@@ -25,6 +25,8 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.Preferences;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import com.google.common.util.concurrent.ListenableFuture;
@@ -117,10 +119,13 @@ public class DownloadedBottomSheetDialog extends BottomSheetDialogFragment imple
TextView removeAll = view.findViewById(R.id.remove_all_text_view);
removeAll.setOnClickListener(v -> {
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
if (Preferences.getDownloadDirectoryUri() == null) {
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
} else {
songs.forEach(ExternalAudioReader::delete);
}
dismissBottomSheet();
});

View File

@@ -13,6 +13,7 @@ import android.widget.TextView;
import android.widget.Toast;
import android.widget.ToggleButton;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
@@ -31,6 +32,7 @@ import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
@@ -39,6 +41,10 @@ import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import com.google.common.util.concurrent.ListenableFuture;
import android.content.Intent;
import androidx.media3.common.MediaItem;
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import java.util.ArrayList;
import java.util.Collections;
@@ -48,6 +54,9 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
private SongBottomSheetViewModel songBottomSheetViewModel;
private Child song;
private TextView downloadButton;
private TextView removeButton;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@Nullable
@@ -66,6 +75,12 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
return view;
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateDownloadButtons);
}
@Override
public void onStart() {
super.onStart();
@@ -157,25 +172,33 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
dismissBottomSheet();
});
TextView download = view.findViewById(R.id.download_text_view);
download.setOnClickListener(v -> {
DownloadUtil.getDownloadTracker(requireContext()).download(
MappingUtil.mapDownload(song),
new Download(song)
);
downloadButton = view.findViewById(R.id.download_text_view);
downloadButton.setOnClickListener(v -> {
if (Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(requireContext()).download(
MappingUtil.mapDownload(song),
new Download(song)
);
} else {
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
}
dismissBottomSheet();
});
TextView remove = view.findViewById(R.id.remove_text_view);
remove.setOnClickListener(v -> {
DownloadUtil.getDownloadTracker(requireContext()).remove(
MappingUtil.mapDownload(song),
new Download(song)
);
removeButton = view.findViewById(R.id.remove_text_view);
removeButton.setOnClickListener(v -> {
if (Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(requireContext()).remove(
MappingUtil.mapDownload(song),
new Download(song)
);
} else {
ExternalAudioReader.delete(song);
}
dismissBottomSheet();
});
initDownloadUI(download, remove);
updateDownloadButtons();
TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view);
addToPlaylist.setOnClickListener(v -> {
@@ -243,12 +266,19 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
dismiss();
}
private void initDownloadUI(TextView download, TextView remove) {
if (DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId())) {
remove.setVisibility(View.VISIBLE);
private void updateDownloadButtons() {
if (downloadButton == null || removeButton == null) {
return;
}
if (Preferences.getDownloadDirectoryUri() == null) {
boolean downloaded = DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId());
downloadButton.setVisibility(downloaded ? View.GONE : View.VISIBLE);
removeButton.setVisibility(downloaded ? View.VISIBLE : View.GONE);
} else {
download.setVisibility(View.VISIBLE);
remove.setVisibility(View.GONE);
boolean hasLocal = ExternalAudioReader.getUri(song) != null;
downloadButton.setVisibility(hasLocal ? View.GONE : View.VISIBLE);
removeButton.setVisibility(hasLocal ? View.VISIBLE : View.GONE);
}
}

View File

@@ -85,6 +85,13 @@ object Constants {
const val MEDIA_LEAST_RECENTLY_STARRED = "MEDIA_LEAST_RECENTLY_STARRED"
const val DOWNLOAD_URI = "rest/download"
const val ACTION_PLAY_EXTERNAL_DOWNLOAD = "com.cappielloantonio.tempo.action.PLAY_EXTERNAL_DOWNLOAD"
const val EXTRA_DOWNLOAD_URI = "EXTRA_DOWNLOAD_URI"
const val EXTRA_DOWNLOAD_MEDIA_ID = "EXTRA_DOWNLOAD_MEDIA_ID"
const val EXTRA_DOWNLOAD_TITLE = "EXTRA_DOWNLOAD_TITLE"
const val EXTRA_DOWNLOAD_ARTIST = "EXTRA_DOWNLOAD_ARTIST"
const val EXTRA_DOWNLOAD_ALBUM = "EXTRA_DOWNLOAD_ALBUM"
const val EXTRA_DOWNLOAD_DURATION = "EXTRA_DOWNLOAD_DURATION"
const val DOWNLOAD_TYPE_TRACK = "download_type_track"
const val DOWNLOAD_TYPE_ALBUM = "download_type_album"
@@ -116,4 +123,13 @@ object Constants {
const val HOME_SECTOR_RECENTLY_ADDED = "HOME_SECTOR_RECENTLY_ADDED"
const val HOME_SECTOR_PINNED_PLAYLISTS = "HOME_SECTOR_PINNED_PLAYLISTS"
const val HOME_SECTOR_SHARED = "HOME_SECTOR_SHARED"
const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON = "android.media3.session.demo.SHUFFLE_ON"
const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF = "android.media3.session.demo.SHUFFLE_OFF"
const val CUSTOM_COMMAND_TOGGLE_HEART_ON = "android.media3.session.demo.HEART_ON"
const val CUSTOM_COMMAND_TOGGLE_HEART_OFF = "android.media3.session.demo.HEART_OFF"
const val CUSTOM_COMMAND_TOGGLE_HEART_LOADING = "android.media3.session.demo.HEART_LOADING"
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF = "android.media3.session.demo.REPEAT_OFF"
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE = "android.media3.session.demo.REPEAT_ONE"
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL = "android.media3.session.demo.REPEAT_ALL"
}

View File

@@ -78,32 +78,26 @@ public final class DownloadUtil {
return httpDataSourceFactory;
}
public static synchronized DataSource.Factory getDataSourceFactory(Context context) {
if (dataSourceFactory == null) {
context = context.getApplicationContext();
public static synchronized DataSource.Factory getUpstreamDataSourceFactory(Context context) {
DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory());
dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
return dataSourceFactory;
}
DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory());
if (Preferences.getStreamingCacheSize() > 0) {
CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory()
.setCache(getStreamingCache(context))
.setUpstreamDataSourceFactory(upstreamFactory);
ResolvingDataSource.Factory resolvingFactory = new ResolvingDataSource.Factory(
new StreamingCacheDataSource.Factory(streamCacheFactory),
dataSpec -> {
DataSpec.Builder builder = dataSpec.buildUpon();
builder.setFlags(dataSpec.flags & ~DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN);
return builder.build();
}
);
dataSourceFactory = buildReadOnlyCacheDataSource(resolvingFactory, getDownloadCache(context));
} else {
dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
}
}
public static synchronized DataSource.Factory getCacheDataSourceFactory(Context context) {
CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory()
.setCache(getStreamingCache(context))
.setUpstreamDataSourceFactory(getUpstreamDataSourceFactory(context));
ResolvingDataSource.Factory resolvingFactory = new ResolvingDataSource.Factory(
new StreamingCacheDataSource.Factory(streamCacheFactory),
dataSpec -> {
DataSpec.Builder builder = dataSpec.buildUpon();
builder.setFlags(dataSpec.flags & ~DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN);
return builder.build();
}
);
dataSourceFactory = buildReadOnlyCacheDataSource(resolvingFactory, getDownloadCache(context));
return dataSourceFactory;
}
@@ -193,19 +187,21 @@ public final class DownloadUtil {
private static synchronized File getDownloadDirectory(Context context) {
if (downloadDirectory == null) {
if (Preferences.getDownloadStoragePreference() == 0) {
int pref = Preferences.getDownloadStoragePreference();
if (pref == 0) {
downloadDirectory = context.getExternalFilesDirs(null)[0];
if (downloadDirectory == null) {
downloadDirectory = context.getFilesDir();
}
} else {
} else if (pref == 1) {
try {
downloadDirectory = context.getExternalFilesDirs(null)[1];
} catch (Exception exception) {
downloadDirectory = context.getExternalFilesDirs(null)[0];
Preferences.setDownloadStoragePreference(0);
}
} else {
downloadDirectory = context.getExternalFilesDirs(null)[0];
}
}

View File

@@ -0,0 +1,69 @@
package com.cappielloantonio.tempo.util
import android.content.Context
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ExtractorsFactory
@UnstableApi
class DynamicMediaSourceFactory(
private val context: Context
) : MediaSource.Factory {
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
val mediaType: String? = mediaItem.mediaMetadata.extras?.getString("type", "")
val streamingCacheSize = Preferences.getStreamingCacheSize()
val bypassCache = mediaType == Constants.MEDIA_TYPE_RADIO
val useUpstream = when {
streamingCacheSize.toInt() == 0 -> true
streamingCacheSize > 0 && bypassCache -> true
streamingCacheSize > 0 && !bypassCache -> false
else -> true
}
val dataSourceFactory: DataSource.Factory = if (useUpstream) {
DownloadUtil.getUpstreamDataSourceFactory(context)
} else {
DownloadUtil.getCacheDataSourceFactory(context)
}
return when {
mediaItem.localConfiguration?.mimeType == MimeTypes.APPLICATION_M3U8 ||
mediaItem.localConfiguration?.uri?.lastPathSegment?.endsWith(".m3u8", ignoreCase = true) == true -> {
HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
}
else -> {
val extractorsFactory: ExtractorsFactory = DefaultExtractorsFactory()
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
.createMediaSource(mediaItem)
}
}
}
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
TODO("Not yet implemented")
}
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
TODO("Not yet implemented")
}
override fun getSupportedTypes(): IntArray {
return intArrayOf(
C.CONTENT_TYPE_HLS,
C.CONTENT_TYPE_OTHER
)
}
}

View File

@@ -0,0 +1,244 @@
package com.cappielloantonio.tempo.util;
import android.net.Uri;
import android.os.Looper;
import android.os.SystemClock;
import androidx.documentfile.provider.DocumentFile;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
import java.text.Normalizer;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExternalAudioReader {
private static final Map<String, DocumentFile> cache = new ConcurrentHashMap<>();
private static final Object LOCK = new Object();
private static final ExecutorService REFRESH_EXECUTOR = Executors.newSingleThreadExecutor();
private static final MutableLiveData<Long> refreshEvents = new MutableLiveData<>();
private static volatile String cachedDirUri;
private static volatile boolean refreshInProgress = false;
private static volatile boolean refreshQueued = false;
private static String sanitizeFileName(String name) {
String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_");
sanitized = sanitized.replaceAll("\\s+", " ").trim();
return sanitized;
}
private static String normalizeForComparison(String name) {
String s = sanitizeFileName(name);
s = Normalizer.normalize(s, Normalizer.Form.NFKD);
s = s.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
return s.toLowerCase(Locale.ROOT);
}
private static void ensureCache() {
String uriString = Preferences.getDownloadDirectoryUri();
if (uriString == null) {
synchronized (LOCK) {
cache.clear();
cachedDirUri = null;
}
ExternalDownloadMetadataStore.clear();
return;
}
if (uriString.equals(cachedDirUri)) {
return;
}
boolean runSynchronously = false;
synchronized (LOCK) {
if (refreshInProgress) {
return;
}
if (Looper.myLooper() == Looper.getMainLooper()) {
scheduleRefreshLocked();
return;
}
refreshInProgress = true;
runSynchronously = true;
}
if (runSynchronously) {
try {
rebuildCache();
} finally {
onRefreshFinished();
}
}
}
public static void refreshCache() {
refreshCacheAsync();
}
public static void refreshCacheAsync() {
synchronized (LOCK) {
cachedDirUri = null;
cache.clear();
}
requestRefresh();
}
public static LiveData<Long> getRefreshEvents() {
return refreshEvents;
}
private static String buildKey(String artist, String title, String album) {
String name = artist != null && !artist.isEmpty() ? artist + " - " + title : title;
if (album != null && !album.isEmpty()) name += " (" + album + ")";
return normalizeForComparison(name);
}
private static Uri findUri(String artist, String title, String album) {
ensureCache();
if (cachedDirUri == null) return null;
DocumentFile file = cache.get(buildKey(artist, title, album));
return file != null && file.exists() ? file.getUri() : null;
}
public static Uri getUri(Child media) {
return findUri(media.getArtist(), media.getTitle(), media.getAlbum());
}
public static Uri getUri(PodcastEpisode episode) {
return findUri(episode.getArtist(), episode.getTitle(), episode.getAlbum());
}
public static synchronized void removeMetadata(Child media) {
if (media == null) {
return;
}
String key = buildKey(media.getArtist(), media.getTitle(), media.getAlbum());
cache.remove(key);
ExternalDownloadMetadataStore.remove(key);
}
public static boolean delete(Child media) {
ensureCache();
if (cachedDirUri == null) return false;
String key = buildKey(media.getArtist(), media.getTitle(), media.getAlbum());
DocumentFile file = cache.get(key);
boolean deleted = false;
if (file != null && file.exists()) {
deleted = file.delete();
}
if (deleted) {
cache.remove(key);
ExternalDownloadMetadataStore.remove(key);
}
return deleted;
}
private static void requestRefresh() {
synchronized (LOCK) {
scheduleRefreshLocked();
}
}
private static void scheduleRefreshLocked() {
if (refreshInProgress) {
refreshQueued = true;
return;
}
refreshInProgress = true;
REFRESH_EXECUTOR.execute(() -> {
try {
rebuildCache();
} finally {
onRefreshFinished();
}
});
}
private static void rebuildCache() {
String uriString = Preferences.getDownloadDirectoryUri();
if (uriString == null) {
synchronized (LOCK) {
cache.clear();
cachedDirUri = null;
}
ExternalDownloadMetadataStore.clear();
return;
}
DocumentFile directory = DocumentFile.fromTreeUri(App.getContext(), Uri.parse(uriString));
Map<String, Long> expectedSizes = ExternalDownloadMetadataStore.snapshot();
Set<String> verifiedKeys = new HashSet<>();
Map<String, DocumentFile> newEntries = new HashMap<>();
if (directory != null && directory.canRead()) {
for (DocumentFile file : directory.listFiles()) {
if (file == null || file.isDirectory()) continue;
String existing = file.getName();
if (existing == null) continue;
String base = existing.replaceFirst("\\.[^\\.]+$", "");
String key = normalizeForComparison(base);
Long expected = expectedSizes.get(key);
long actualLength = file.length();
if (expected != null && expected > 0 && actualLength == expected) {
newEntries.put(key, file);
verifiedKeys.add(key);
} else {
ExternalDownloadMetadataStore.remove(key);
}
}
}
if (!expectedSizes.isEmpty()) {
if (verifiedKeys.isEmpty()) {
ExternalDownloadMetadataStore.clear();
} else {
for (String key : expectedSizes.keySet()) {
if (!verifiedKeys.contains(key)) {
ExternalDownloadMetadataStore.remove(key);
}
}
}
}
synchronized (LOCK) {
cache.clear();
cache.putAll(newEntries);
cachedDirUri = uriString;
}
}
private static void onRefreshFinished() {
boolean runAgain;
synchronized (LOCK) {
refreshInProgress = false;
runAgain = refreshQueued;
refreshQueued = false;
}
refreshEvents.postValue(SystemClock.elapsedRealtime());
if (runAgain) {
requestRefresh();
}
}
}

View File

@@ -0,0 +1,322 @@
package com.cappielloantonio.tempo.util;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.provider.Settings;
import android.webkit.MimeTypeMap;
import androidx.core.app.NotificationCompat;
import androidx.documentfile.provider.DocumentFile;
import androidx.media3.common.MediaItem;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.text.Normalizer;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExternalAudioWriter {
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
private static final int BUFFER_SIZE = 8192;
private static final int CONNECT_TIMEOUT_MS = 15_000;
private static final int READ_TIMEOUT_MS = 60_000;
private ExternalAudioWriter() {
}
private static String sanitizeFileName(String name) {
String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_");
sanitized = sanitized.replaceAll("\\s+", " ").trim();
return sanitized;
}
private static String normalizeForComparison(String name) {
String s = sanitizeFileName(name);
s = Normalizer.normalize(s, Normalizer.Form.NFKD);
s = s.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
return s.toLowerCase(Locale.ROOT);
}
private static DocumentFile findFile(DocumentFile dir, String fileName) {
String normalized = normalizeForComparison(fileName);
for (DocumentFile file : dir.listFiles()) {
if (file.isDirectory()) continue;
String existing = file.getName();
if (existing != null && normalizeForComparison(existing).equals(normalized)) {
return file;
}
}
return null;
}
public static void downloadToUserDirectory(Context context, Child child) {
if (context == null || child == null) {
return;
}
Context appContext = context.getApplicationContext();
MediaItem mediaItem = MappingUtil.mapDownload(child);
String fallbackName = child.getTitle() != null ? child.getTitle() : child.getId();
EXECUTOR.execute(() -> performDownload(appContext, mediaItem, fallbackName, child));
}
private static void performDownload(Context context, MediaItem mediaItem, String fallbackName, Child child) {
String uriString = Preferences.getDownloadDirectoryUri();
if (uriString == null) {
notifyUnavailable(context);
return;
}
DocumentFile directory = DocumentFile.fromTreeUri(context, Uri.parse(uriString));
if (directory == null || !directory.canWrite()) {
notifyFailure(context, "Cannot write to folder.");
return;
}
String artist = child.getArtist() != null ? child.getArtist() : "";
String title = child.getTitle() != null ? child.getTitle() : fallbackName;
String album = child.getAlbum() != null ? child.getAlbum() : "";
String baseName = artist.isEmpty() ? title : artist + " - " + title;
if (!album.isEmpty()) baseName += " (" + album + ")";
if (baseName.isEmpty()) {
baseName = fallbackName != null ? fallbackName : "download";
}
String metadataKey = normalizeForComparison(baseName);
Uri mediaUri = mediaItem != null && mediaItem.requestMetadata != null
? mediaItem.requestMetadata.mediaUri
: null;
if (mediaUri == null) {
notifyFailure(context, "Invalid media URI.");
ExternalDownloadMetadataStore.remove(metadataKey);
return;
}
String scheme = mediaUri.getScheme();
if (scheme == null || (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https"))) {
notifyFailure(context, "Unsupported media URI.");
ExternalDownloadMetadataStore.remove(metadataKey);
return;
}
HttpURLConnection connection = null;
DocumentFile targetFile = null;
try {
connection = (HttpURLConnection) new URL(mediaUri.toString()).openConnection();
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(READ_TIMEOUT_MS);
connection.setRequestProperty("Accept-Encoding", "identity");
connection.connect();
int responseCode = connection.getResponseCode();
if (responseCode >= HttpURLConnection.HTTP_BAD_REQUEST) {
notifyFailure(context, "Server returned " + responseCode);
ExternalDownloadMetadataStore.remove(metadataKey);
return;
}
String mimeType = connection.getContentType();
if (mimeType == null || mimeType.isEmpty()) {
mimeType = "application/octet-stream";
}
String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
if (extension == null || extension.isEmpty()) {
String suffix = child.getSuffix();
if (suffix != null && !suffix.isEmpty()) {
extension = suffix;
} else {
extension = "bin";
}
}
String sanitized = sanitizeFileName(baseName);
if (sanitized.isEmpty()) sanitized = sanitizeFileName(fallbackName);
if (sanitized.isEmpty()) sanitized = "download";
String fileName = sanitized + "." + extension;
DocumentFile existingFile = findFile(directory, fileName);
long remoteLength = connection.getContentLengthLong();
Long recordedSize = ExternalDownloadMetadataStore.getSize(metadataKey);
if (existingFile != null && existingFile.exists()) {
long localLength = existingFile.length();
boolean matches = false;
if (remoteLength > 0 && localLength == remoteLength) {
matches = true;
} else if (remoteLength <= 0 && recordedSize != null && localLength == recordedSize) {
matches = true;
}
if (matches) {
ExternalDownloadMetadataStore.recordSize(metadataKey, localLength);
recordDownload(child, existingFile.getUri());
ExternalAudioReader.refreshCacheAsync();
notifyExists(context, fileName);
return;
} else {
existingFile.delete();
ExternalDownloadMetadataStore.remove(metadataKey);
}
}
targetFile = directory.createFile(mimeType, fileName);
if (targetFile == null) {
notifyFailure(context, "Failed to create file.");
return;
}
Uri targetUri = targetFile.getUri();
try (InputStream in = connection.getInputStream();
OutputStream out = context.getContentResolver().openOutputStream(targetUri)) {
if (out == null) {
notifyFailure(context, "Cannot open output stream.");
targetFile.delete();
return;
}
byte[] buffer = new byte[BUFFER_SIZE];
int len;
long total = 0;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
total += len;
}
out.flush();
if (total <= 0) {
targetFile.delete();
ExternalDownloadMetadataStore.remove(metadataKey);
notifyFailure(context, "Empty download.");
return;
}
if (remoteLength > 0 && total != remoteLength) {
targetFile.delete();
ExternalDownloadMetadataStore.remove(metadataKey);
notifyFailure(context, "Incomplete download.");
return;
}
ExternalDownloadMetadataStore.recordSize(metadataKey, total);
recordDownload(child, targetUri);
notifySuccess(context, fileName, child, targetUri);
ExternalAudioReader.refreshCacheAsync();
}
} catch (Exception e) {
if (targetFile != null) {
targetFile.delete();
}
ExternalDownloadMetadataStore.remove(metadataKey);
notifyFailure(context, e.getMessage() != null ? e.getMessage() : "Download failed");
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private static void notifyUnavailable(Context context) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
Intent settingsIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.getPackageName(), null));
PendingIntent openSettings = PendingIntent.getActivity(context, 0, settingsIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
.setContentTitle("No download folder set")
.setContentText("Tap to set one in settings")
.setSmallIcon(android.R.drawable.stat_notify_error)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSilent(true)
.setContentIntent(openSettings)
.setAutoCancel(true);
manager.notify(1011, builder.build());
}
private static void notifyFailure(Context context, String message) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
.setContentTitle("Download failed")
.setContentText(message)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setAutoCancel(true);
manager.notify((int) System.currentTimeMillis(), builder.build());
}
private static void notifySuccess(Context context, String name, Child child, Uri fileUri) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
.setContentTitle("Download complete")
.setContentText(name)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setAutoCancel(true);
PendingIntent playIntent = buildPlayIntent(context, child, fileUri);
if (playIntent != null) {
builder.setContentIntent(playIntent);
}
manager.notify((int) System.currentTimeMillis(), builder.build());
}
private static void recordDownload(Child child, Uri fileUri) {
if (child == null) {
return;
}
Download download = new Download(child);
download.setDownloadState(1);
if (fileUri != null) {
download.setDownloadUri(fileUri.toString());
}
new DownloadRepository().insert(download);
}
private static void notifyExists(Context context, String name) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
.setContentTitle("Already downloaded")
.setContentText(name)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setAutoCancel(true);
manager.notify((int) System.currentTimeMillis(), builder.build());
}
private static PendingIntent buildPlayIntent(Context context, Child child, Uri fileUri) {
if (fileUri == null) return null;
Intent intent = new Intent(context, MainActivity.class)
.setAction(Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD)
.putExtra(Constants.EXTRA_DOWNLOAD_URI, fileUri.toString())
.putExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID, child.getId())
.putExtra(Constants.EXTRA_DOWNLOAD_TITLE, child.getTitle())
.putExtra(Constants.EXTRA_DOWNLOAD_ARTIST, child.getArtist())
.putExtra(Constants.EXTRA_DOWNLOAD_ALBUM, child.getAlbum())
.putExtra(Constants.EXTRA_DOWNLOAD_DURATION, child.getDuration() != null ? child.getDuration() : 0)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
int requestCode;
if (child.getId() != null) {
requestCode = Math.abs(child.getId().hashCode());
} else {
requestCode = Math.abs(fileUri.toString().hashCode());
}
return PendingIntent.getActivity(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
}
}

View File

@@ -0,0 +1,123 @@
package com.cappielloantonio.tempo.util;
import android.content.SharedPreferences;
import androidx.annotation.Nullable;
import com.cappielloantonio.tempo.App;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
public final class ExternalDownloadMetadataStore {
private static final String PREF_KEY = "external_download_metadata";
private ExternalDownloadMetadataStore() {
}
private static SharedPreferences preferences() {
return App.getInstance().getPreferences();
}
private static JSONObject readAll() {
String raw = preferences().getString(PREF_KEY, "{}");
try {
return new JSONObject(raw);
} catch (JSONException e) {
return new JSONObject();
}
}
private static void writeAll(JSONObject object) {
preferences().edit().putString(PREF_KEY, object.toString()).apply();
}
public static synchronized void clear() {
writeAll(new JSONObject());
}
public static synchronized void recordSize(String key, long size) {
if (key == null || size <= 0) {
return;
}
JSONObject object = readAll();
try {
object.put(key, size);
} catch (JSONException ignored) {
}
writeAll(object);
}
public static synchronized void remove(String key) {
if (key == null) {
return;
}
JSONObject object = readAll();
object.remove(key);
writeAll(object);
}
@Nullable
public static synchronized Long getSize(String key) {
if (key == null) {
return null;
}
JSONObject object = readAll();
if (!object.has(key)) {
return null;
}
long size = object.optLong(key, -1L);
return size > 0 ? size : null;
}
public static synchronized Map<String, Long> snapshot() {
JSONObject object = readAll();
if (object.length() == 0) {
return Collections.emptyMap();
}
Map<String, Long> sizes = new HashMap<>();
Iterator<String> keys = object.keys();
while (keys.hasNext()) {
String key = keys.next();
long size = object.optLong(key, -1L);
if (size > 0) {
sizes.put(key, size);
}
}
return sizes;
}
public static synchronized void retainOnly(Set<String> keysToKeep) {
if (keysToKeep == null || keysToKeep.isEmpty()) {
clear();
return;
}
JSONObject object = readAll();
if (object.length() == 0) {
return;
}
Set<String> keys = new HashSet<>();
Iterator<String> iterator = object.keys();
while (iterator.hasNext()) {
keys.add(iterator.next());
}
boolean changed = false;
for (String key : keys) {
if (!keysToKeep.contains(key)) {
object.remove(key);
changed = true;
}
}
if (changed) {
writeAll(object);
}
}
}

View File

@@ -4,10 +4,12 @@ import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.HeartRating;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
@@ -16,6 +18,7 @@ import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.List;
@@ -83,6 +86,13 @@ public class MappingUtil {
.setAlbumTitle(media.getAlbum())
.setArtist(media.getArtist())
.setArtworkUri(artworkUri)
.setUserRating(new HeartRating(media.getStarred() != null))
.setSupportedCommands(
ImmutableList.of(
Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON,
Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
)
)
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
@@ -140,7 +150,6 @@ public class MappingUtil {
Bundle bundle = new Bundle();
bundle.putString("id", internetRadioStation.getId());
bundle.putString("title", internetRadioStation.getName());
bundle.putString("artist", uri.toString());
bundle.putString("uri", uri.toString());
bundle.putString("type", Constants.MEDIA_TYPE_RADIO);
@@ -149,7 +158,6 @@ public class MappingUtil {
.setMediaMetadata(
new MediaMetadata.Builder()
.setTitle(internetRadioStation.getName())
.setArtist(internetRadioStation.getStreamUrl())
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
@@ -219,12 +227,20 @@ public class MappingUtil {
}
private static Uri getUri(Child media) {
if (Preferences.getDownloadDirectoryUri() != null) {
Uri local = ExternalAudioReader.getUri(media);
return local != null ? local : MusicUtil.getStreamUri(media.getId());
}
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(media.getId())
? getDownloadUri(media.getId())
: MusicUtil.getStreamUri(media.getId());
}
private static Uri getUri(PodcastEpisode podcastEpisode) {
if (Preferences.getDownloadDirectoryUri() != null) {
Uri local = ExternalAudioReader.getUri(podcastEpisode);
return local != null ? local : MusicUtil.getStreamUri(podcastEpisode.getStreamId());
}
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(podcastEpisode.getStreamId())
? getDownloadUri(podcastEpisode.getStreamId())
: MusicUtil.getStreamUri(podcastEpisode.getStreamId());
@@ -234,4 +250,11 @@ public class MappingUtil {
Download download = new DownloadRepository().getDownload(id);
return download != null && !download.getDownloadUri().isEmpty() ? Uri.parse(download.getDownloadUri()) : MusicUtil.getDownloadUri(id);
}
public static void observeExternalAudioRefresh(LifecycleOwner owner, Runnable onRefresh) {
if (owner == null || onRefresh == null) {
return;
}
ExternalAudioReader.getRefreshEvents().observe(owner, event -> onRefresh.run());
}
}

View File

@@ -37,6 +37,8 @@ object Preferences {
private const val WIFI_ONLY = "wifi_only"
private const val DATA_SAVING_MODE = "data_saving_mode"
private const val SERVER_UNREACHABLE = "server_unreachable"
private const val SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE = "sync_starred_artists_for_offline_use"
private const val SYNC_STARRED_ALBUMS_FOR_OFFLINE_USE = "sync_starred_albums_for_offline_use"
private const val SYNC_STARRED_TRACKS_FOR_OFFLINE_USE = "sync_starred_tracks_for_offline_use"
private const val QUEUE_SYNCING = "queue_syncing"
private const val QUEUE_SYNCING_COUNTDOWN = "queue_syncing_countdown"
@@ -44,11 +46,13 @@ object Preferences {
private const val ROUNDED_CORNER_SIZE = "rounded_corner_size"
private const val PODCAST_SECTION_VISIBILITY = "podcast_section_visibility"
private const val RADIO_SECTION_VISIBILITY = "radio_section_visibility"
private const val AUTO_DOWNLOAD_LYRICS = "auto_download_lyrics"
private const val MUSIC_DIRECTORY_SECTION_VISIBILITY = "music_directory_section_visibility"
private const val REPLAY_GAIN_MODE = "replay_gain_mode"
private const val AUDIO_TRANSCODE_PRIORITY = "audio_transcode_priority"
private const val STREAMING_CACHE_STORAGE = "streaming_cache_storage"
private const val DOWNLOAD_STORAGE = "download_storage"
private const val DOWNLOAD_DIRECTORY_URI = "download_directory_uri"
private const val DEFAULT_DOWNLOAD_VIEW_TYPE = "default_download_view_type"
private const val AUDIO_TRANSCODE_DOWNLOAD = "audio_transcode_download"
private const val AUDIO_TRANSCODE_DOWNLOAD_PRIORITY = "audio_transcode_download_priority"
@@ -63,11 +67,15 @@ object Preferences {
private const val ALWAYS_ON_DISPLAY = "always_on_display"
private const val AUDIO_QUALITY_PER_ITEM = "audio_quality_per_item"
private const val HOME_SECTOR_LIST = "home_sector_list"
private const val SONG_RATING_PER_ITEM = "song_rating_per_item"
private const val RATING_PER_ITEM = "rating_per_item"
private const val NEXT_UPDATE_CHECK = "next_update_check"
private const val CONTINUOUS_PLAY = "continuous_play"
private const val LAST_INSTANT_MIX = "last_instant_mix"
private const val ALLOW_PLAYLIST_DUPLICATES = "allow_playlist_duplicates"
private const val EQUALIZER_ENABLED = "equalizer_enabled"
private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels"
private const val MINI_SHUFFLE_BUTTON_VISIBILITY = "mini_shuffle_button_visibility"
@JvmStatic
fun getServer(): String? {
@@ -159,6 +167,24 @@ object Preferences {
App.getInstance().preferences.edit().putString(OPEN_SUBSONIC_EXTENSIONS, Gson().toJson(extension)).apply()
}
@JvmStatic
fun isAutoDownloadLyricsEnabled(): Boolean {
val preferences = App.getInstance().preferences
if (preferences.contains(AUTO_DOWNLOAD_LYRICS)) {
return preferences.getBoolean(AUTO_DOWNLOAD_LYRICS, false)
}
return false
}
@JvmStatic
fun setAutoDownloadLyricsEnabled(isEnabled: Boolean) {
App.getInstance().preferences.edit()
.putBoolean(AUTO_DOWNLOAD_LYRICS, isEnabled)
.apply()
}
@JvmStatic
fun getLocalAddress(): String? {
return App.getInstance().preferences.getString(LOCAL_ADDRESS, null)
@@ -300,6 +326,30 @@ object Preferences {
.apply()
}
@JvmStatic
fun isStarredArtistsSyncEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE, false)
}
@JvmStatic
fun setStarredArtistsSyncEnabled(isStarredSyncEnabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(
SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE, isStarredSyncEnabled
).apply()
}
@JvmStatic
fun isStarredAlbumsSyncEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(SYNC_STARRED_ALBUMS_FOR_OFFLINE_USE, false)
}
@JvmStatic
fun setStarredAlbumsSyncEnabled(isStarredSyncEnabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(
SYNC_STARRED_ALBUMS_FOR_OFFLINE_USE, isStarredSyncEnabled
).apply()
}
@JvmStatic
fun isStarredSyncEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(SYNC_STARRED_TRACKS_FOR_OFFLINE_USE, false)
@@ -312,6 +362,16 @@ object Preferences {
).apply()
}
@JvmStatic
fun showShuffleInsteadOfHeart(): Boolean {
return App.getInstance().preferences.getBoolean(MINI_SHUFFLE_BUTTON_VISIBILITY, false)
}
@JvmStatic
fun setShuffleInsteadOfHeart(enabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(MINI_SHUFFLE_BUTTON_VISIBILITY, enabled).apply()
}
@JvmStatic
fun showServerUnreachableDialog(): Boolean {
return App.getInstance().preferences.getLong(
@@ -405,6 +465,20 @@ object Preferences {
).apply()
}
@JvmStatic
fun getDownloadDirectoryUri(): String? {
return App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null)
}
@JvmStatic
fun setDownloadDirectoryUri(uri: String?) {
val current = App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null)
if (current != uri) {
ExternalDownloadMetadataStore.clear()
}
App.getInstance().preferences.edit().putString(DOWNLOAD_DIRECTORY_URI, uri).apply()
}
@JvmStatic
fun getDefaultDownloadViewType(): String {
return App.getInstance().preferences.getString(
@@ -486,6 +560,11 @@ object Preferences {
App.getInstance().preferences.edit().putString(HOME_SECTOR_LIST, Gson().toJson(extension)).apply()
}
@JvmStatic
fun showItemStarRating(): Boolean {
return App.getInstance().preferences.getBoolean(SONG_RATING_PER_ITEM, false)
}
@JvmStatic
fun showItemRating(): Boolean {
return App.getInstance().preferences.getBoolean(RATING_PER_ITEM, false)
@@ -519,4 +598,44 @@ object Preferences {
LAST_INSTANT_MIX, 0
) + 5000 < System.currentTimeMillis()
}
@JvmStatic
fun setAllowPlaylistDuplicates(allowDuplicates: Boolean) {
return App.getInstance().preferences.edit().putString(
ALLOW_PLAYLIST_DUPLICATES,
allowDuplicates.toString()
).apply()
}
@JvmStatic
fun allowPlaylistDuplicates(): Boolean {
return App.getInstance().preferences.getBoolean(ALLOW_PLAYLIST_DUPLICATES, false)
}
@JvmStatic
fun setEqualizerEnabled(enabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(EQUALIZER_ENABLED, enabled).apply()
}
@JvmStatic
fun isEqualizerEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(EQUALIZER_ENABLED, false)
}
@JvmStatic
fun setEqualizerBandLevels(bandLevels: ShortArray) {
val asString = bandLevels.joinToString(",")
App.getInstance().preferences.edit().putString(EQUALIZER_BAND_LEVELS, asString).apply()
}
@JvmStatic
fun getEqualizerBandLevels(bandCount: Short): ShortArray {
val str = App.getInstance().preferences.getString(EQUALIZER_BAND_LEVELS, null)
if (str.isNullOrBlank()) {
return ShortArray(bandCount.toInt())
}
val parts = str.split(",")
if (parts.size < bandCount) return ShortArray(bandCount.toInt())
return ShortArray(bandCount.toInt()) { i -> parts[i].toShortOrNull() ?: 0 }
}
}

View File

@@ -1,12 +1,15 @@
package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.ArtistRepository;
@@ -16,10 +19,14 @@ 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.Share;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.NetworkUtil;
import com.cappielloantonio.tempo.util.Preferences;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
public class AlbumBottomSheetViewModel extends AndroidViewModel {
private final AlbumRepository albumRepository;
@@ -54,7 +61,7 @@ public class AlbumBottomSheetViewModel extends AndroidViewModel {
return albumRepository.getAlbumTracks(album.getId());
}
public void setFavorite() {
public void setFavorite(Context context) {
if (album.getStarred() != null) {
if (NetworkUtil.isOffline()) {
removeFavoriteOffline();
@@ -65,7 +72,7 @@ public class AlbumBottomSheetViewModel extends AndroidViewModel {
if (NetworkUtil.isOffline()) {
setFavoriteOffline();
} else {
setFavoriteOnline();
setFavoriteOnline(context);
}
}
}
@@ -83,7 +90,6 @@ public class AlbumBottomSheetViewModel extends AndroidViewModel {
favoriteRepository.unstar(null, album.getId(), null, new StarCallback() {
@Override
public void onError() {
// album.setStarred(new Date());
favoriteRepository.starLater(null, album.getId(), null, false);
}
});
@@ -96,15 +102,31 @@ public class AlbumBottomSheetViewModel extends AndroidViewModel {
album.setStarred(new Date());
}
private void setFavoriteOnline() {
private void setFavoriteOnline(Context context) {
favoriteRepository.star(null, album.getId(), null, new StarCallback() {
@Override
public void onError() {
// album.setStarred(null);
favoriteRepository.starLater(null, album.getId(), null, true);
}
});
album.setStarred(new Date());
if (Preferences.isStarredAlbumsSyncEnabled()) {
AlbumRepository albumRepository = new AlbumRepository();
MutableLiveData<List<Child>> tracksLiveData = albumRepository.getAlbumTracks(album.getId());
tracksLiveData.observeForever(new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> songs) {
if (songs != null && !songs.isEmpty()) {
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownloads(songs),
songs.stream().map(Download::new).collect(Collectors.toList())
);
}
tracksLiveData.removeObserver(this);
}
});
}
}
}

View File

@@ -1,17 +1,25 @@
package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.FavoriteRepository;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.util.NetworkUtil;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.Preferences;
import java.util.Date;
import java.util.stream.Collectors;
import java.util.List;
public class ArtistBottomSheetViewModel extends AndroidViewModel {
private final ArtistRepository artistRepository;
@@ -34,7 +42,7 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
this.artist = artist;
}
public void setFavorite() {
public void setFavorite(Context context) {
if (artist.getStarred() != null) {
if (NetworkUtil.isOffline()) {
removeFavoriteOffline();
@@ -43,9 +51,9 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
}
} else {
if (NetworkUtil.isOffline()) {
setFavoriteOffline();
setFavoriteOffline(context);
} else {
setFavoriteOnline();
setFavoriteOnline(context);
}
}
}
@@ -59,7 +67,6 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
favoriteRepository.unstar(null, null, artist.getId(), new StarCallback() {
@Override
public void onError() {
// artist.setStarred(new Date());
favoriteRepository.starLater(null, null, artist.getId(), false);
}
});
@@ -67,20 +74,45 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
artist.setStarred(null);
}
private void setFavoriteOffline() {
private void setFavoriteOffline(Context context) {
favoriteRepository.starLater(null, null, artist.getId(), true);
artist.setStarred(new Date());
}
private void setFavoriteOnline() {
private void setFavoriteOnline(Context context) {
favoriteRepository.star(null, null, artist.getId(), new StarCallback() {
@Override
public void onError() {
// artist.setStarred(null);
favoriteRepository.starLater(null, null, artist.getId(), true);
}
});
artist.setStarred(new Date());
Log.d("ArtistSync", "Checking preference: " + Preferences.isStarredArtistsSyncEnabled());
if (Preferences.isStarredArtistsSyncEnabled()) {
Log.d("ArtistSync", "Starting artist sync for: " + artist.getName());
artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() {
@Override
public void onSongsCollected(List<Child> songs) {
Log.d("ArtistSync", "Callback triggered with songs: " + (songs != null ? songs.size() : 0));
if (songs != null && !songs.isEmpty()) {
Log.d("ArtistSync", "Starting download of " + songs.size() + " songs");
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownloads(songs),
songs.stream().map(Download::new).collect(Collectors.toList())
);
Log.d("ArtistSync", "Download started successfully");
} else {
Log.d("ArtistSync", "No songs to download");
}
}
});
} else {
Log.d("ArtistSync", "Artist sync preference is disabled");
}
}
///
}

View File

@@ -1,6 +1,7 @@
package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
import android.net.Uri;
import android.util.Log;
import androidx.annotation.NonNull;
@@ -8,10 +9,13 @@ import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.documentfile.provider.DocumentFile;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.model.DownloadStack;
import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.Preferences;
import java.util.ArrayList;
@@ -25,6 +29,7 @@ public class DownloadViewModel extends AndroidViewModel {
private final MutableLiveData<List<Child>> downloadedTrackSample = new MutableLiveData<>(null);
private final MutableLiveData<ArrayList<DownloadStack>> viewStack = new MutableLiveData<>(null);
private final MutableLiveData<Integer> refreshResult = new MutableLiveData<>();
public DownloadViewModel(@NonNull Application application) {
super(application);
@@ -43,6 +48,10 @@ public class DownloadViewModel extends AndroidViewModel {
return viewStack;
}
public LiveData<Integer> getRefreshResult() {
return refreshResult;
}
public void initViewStack(DownloadStack level) {
ArrayList<DownloadStack> stack = new ArrayList<>();
stack.add(level);
@@ -60,4 +69,59 @@ public class DownloadViewModel extends AndroidViewModel {
stack.remove(stack.size() - 1);
viewStack.setValue(stack);
}
public void refreshExternalDownloads() {
new Thread(() -> {
String directoryUri = Preferences.getDownloadDirectoryUri();
if (directoryUri == null) {
refreshResult.postValue(-1);
return;
}
List<Download> downloads = downloadRepository.getAllDownloads();
if (downloads == null || downloads.isEmpty()) {
refreshResult.postValue(0);
return;
}
ArrayList<Download> toRemove = new ArrayList<>();
for (Download download : downloads) {
String uriString = download.getDownloadUri();
if (uriString == null || uriString.isEmpty()) {
continue;
}
Uri uri = Uri.parse(uriString);
if (uri.getScheme() == null || !uri.getScheme().equalsIgnoreCase("content")) {
continue;
}
DocumentFile file;
try {
file = DocumentFile.fromSingleUri(getApplication(), uri);
} catch (SecurityException exception) {
file = null;
}
if (file == null || !file.exists()) {
toRemove.add(download);
}
}
if (!toRemove.isEmpty()) {
ArrayList<String> ids = new ArrayList<>();
for (Download download : toRemove) {
ids.add(download.getId());
ExternalAudioReader.removeMetadata(download);
}
downloadRepository.delete(ids);
ExternalAudioReader.refreshCache();
refreshResult.postValue(ids.size());
} else {
refreshResult.postValue(0);
}
}).start();
}
}

View File

@@ -47,6 +47,9 @@ public class HomeViewModel extends AndroidViewModel {
private final PlaylistRepository playlistRepository;
private final SharingRepository sharingRepository;
private final StarredAlbumsSyncViewModel albumsSyncViewModel;
private final StarredArtistsSyncViewModel artistSyncViewModel;
private final MutableLiveData<List<Child>> dicoverSongSample = new MutableLiveData<>(null);
private final MutableLiveData<List<AlbumID3>> newReleasedAlbum = new MutableLiveData<>(null);
private final MutableLiveData<List<Child>> starredTracksSample = new MutableLiveData<>(null);
@@ -82,6 +85,9 @@ public class HomeViewModel extends AndroidViewModel {
playlistRepository = new PlaylistRepository();
sharingRepository = new SharingRepository();
albumsSyncViewModel = new StarredAlbumsSyncViewModel(application);
artistSyncViewModel = new StarredArtistsSyncViewModel(application);
setOfflineFavorite();
}
@@ -166,6 +172,14 @@ public class HomeViewModel extends AndroidViewModel {
return starredAlbums;
}
public LiveData<List<Child>> getAllStarredAlbumSongs() {
return albumsSyncViewModel.getAllStarredAlbumSongs();
}
public LiveData<List<Child>> getAllStarredArtistSongs() {
return artistSyncViewModel.getAllStarredArtistSongs();
}
public LiveData<List<ArtistID3>> getStarredArtists(LifecycleOwner owner) {
if (starredArtists.getValue() == null) {
artistRepository.getStarredArtists(true, 20).observe(owner, starredArtists::postValue);

View File

@@ -0,0 +1,35 @@
package com.cappielloantonio.tempo.viewmodel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import java.util.Objects;
public class PlaybackViewModel extends ViewModel {
private final MutableLiveData<String> currentSongId = new MutableLiveData<>(null);
private final MutableLiveData<Boolean> isPlaying = new MutableLiveData<>(false);
public LiveData<String> getCurrentSongId() {
return currentSongId;
}
public LiveData<Boolean> getIsPlaying() {
return isPlaying;
}
public void update(String songId, boolean playing) {
if (!Objects.equals(currentSongId.getValue(), songId)) {
currentSongId.postValue(songId);
}
if (!Objects.equals(isPlaying.getValue(), playing)) {
isPlaying.postValue(playing);
}
}
public void clear() {
currentSongId.postValue(null);
isPlaying.postValue(false);
}
}

View File

@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
import android.content.Context;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
@@ -9,14 +10,17 @@ import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.model.LyricsCache;
import com.cappielloantonio.tempo.model.Queue;
import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.FavoriteRepository;
import com.cappielloantonio.tempo.repository.LyricsRepository;
import com.cappielloantonio.tempo.repository.OpenRepository;
import com.cappielloantonio.tempo.repository.QueueRepository;
import com.cappielloantonio.tempo.repository.SongRepository;
@@ -31,6 +35,7 @@ import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.NetworkUtil;
import com.cappielloantonio.tempo.util.OpenSubsonicExtensionsUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.google.gson.Gson;
import java.util.Collections;
import java.util.Date;
@@ -47,14 +52,20 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
private final QueueRepository queueRepository;
private final FavoriteRepository favoriteRepository;
private final OpenRepository openRepository;
private final LyricsRepository lyricsRepository;
private final MutableLiveData<String> lyricsLiveData = new MutableLiveData<>(null);
private final MutableLiveData<LyricsList> lyricsListLiveData = new MutableLiveData<>(null);
private final MutableLiveData<Boolean> lyricsCachedLiveData = new MutableLiveData<>(false);
private final MutableLiveData<String> descriptionLiveData = new MutableLiveData<>(null);
private final MutableLiveData<Child> liveMedia = new MutableLiveData<>(null);
private final MutableLiveData<AlbumID3> liveAlbum = new MutableLiveData<>(null);
private final MutableLiveData<ArtistID3> liveArtist = new MutableLiveData<>(null);
private final MutableLiveData<List<Child>> instantMix = new MutableLiveData<>(null);
private final Gson gson = new Gson();
private boolean lyricsSyncState = true;
private LiveData<LyricsCache> cachedLyricsSource;
private String currentSongId;
private final Observer<LyricsCache> cachedLyricsObserver = this::onCachedLyricsChanged;
public PlayerBottomSheetViewModel(@NonNull Application application) {
@@ -66,6 +77,7 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
queueRepository = new QueueRepository();
favoriteRepository = new FavoriteRepository();
openRepository = new OpenRepository();
lyricsRepository = new LyricsRepository();
}
public LiveData<List<Queue>> getQueueSong() {
@@ -103,7 +115,6 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
favoriteRepository.starLater(media.getId(), null, null, false);
}
});
media.setStarred(null);
}
@@ -123,7 +134,7 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
media.setStarred(new Date());
if (Preferences.isStarredSyncEnabled()) {
if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownload(media),
new Download(media)
@@ -131,7 +142,7 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
}
}
public LiveData<String> getLiveLyrics() {
public LiveData<String> getLiveLyrics() {
return lyricsLiveData;
}
@@ -140,12 +151,49 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
}
public void refreshMediaInfo(LifecycleOwner owner, Child media) {
lyricsLiveData.postValue(null);
lyricsListLiveData.postValue(null);
lyricsCachedLiveData.postValue(false);
clearCachedLyricsObserver();
String songId = media != null ? media.getId() : currentSongId;
if (TextUtils.isEmpty(songId) || owner == null) {
return;
}
currentSongId = songId;
observeCachedLyrics(owner, songId);
LyricsCache cachedLyrics = lyricsRepository.getLyrics(songId);
if (cachedLyrics != null) {
onCachedLyricsChanged(cachedLyrics);
}
if (NetworkUtil.isOffline() || media == null) {
return;
}
if (OpenSubsonicExtensionsUtil.isSongLyricsExtensionAvailable()) {
openRepository.getLyricsBySongId(media.getId()).observe(owner, lyricsListLiveData::postValue);
lyricsLiveData.postValue(null);
openRepository.getLyricsBySongId(media.getId()).observe(owner, lyricsList -> {
lyricsListLiveData.postValue(lyricsList);
lyricsLiveData.postValue(null);
if (shouldAutoDownloadLyrics() && hasStructuredLyrics(lyricsList)) {
saveLyricsToCache(media, null, lyricsList);
}
});
} else {
songRepository.getSongLyrics(media).observe(owner, lyricsLiveData::postValue);
lyricsListLiveData.postValue(null);
songRepository.getSongLyrics(media).observe(owner, lyrics -> {
lyricsLiveData.postValue(lyrics);
lyricsListLiveData.postValue(null);
if (shouldAutoDownloadLyrics() && !TextUtils.isEmpty(lyrics)) {
saveLyricsToCache(media, lyrics, null);
}
});
}
}
@@ -154,6 +202,17 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
}
public void setLiveMedia(LifecycleOwner owner, String mediaType, String mediaId) {
currentSongId = mediaId;
if (!TextUtils.isEmpty(mediaId)) {
refreshMediaInfo(owner, null);
} else {
clearCachedLyricsObserver();
lyricsLiveData.postValue(null);
lyricsListLiveData.postValue(null);
lyricsCachedLiveData.postValue(false);
}
if (mediaType != null) {
switch (mediaType) {
case Constants.MEDIA_TYPE_MUSIC:
@@ -163,7 +222,12 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
case Constants.MEDIA_TYPE_PODCAST:
liveMedia.postValue(null);
break;
default:
liveMedia.postValue(null);
break;
}
} else {
liveMedia.postValue(null);
}
}
@@ -234,6 +298,105 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
return false;
}
private void observeCachedLyrics(LifecycleOwner owner, String songId) {
if (TextUtils.isEmpty(songId)) {
return;
}
cachedLyricsSource = lyricsRepository.observeLyrics(songId);
cachedLyricsSource.observe(owner, cachedLyricsObserver);
}
private void clearCachedLyricsObserver() {
if (cachedLyricsSource != null) {
cachedLyricsSource.removeObserver(cachedLyricsObserver);
cachedLyricsSource = null;
}
}
private void onCachedLyricsChanged(LyricsCache lyricsCache) {
if (lyricsCache == null) {
lyricsCachedLiveData.postValue(false);
return;
}
lyricsCachedLiveData.postValue(true);
if (!TextUtils.isEmpty(lyricsCache.getStructuredLyrics())) {
try {
LyricsList cachedList = gson.fromJson(lyricsCache.getStructuredLyrics(), LyricsList.class);
lyricsListLiveData.postValue(cachedList);
lyricsLiveData.postValue(null);
} catch (Exception exception) {
lyricsListLiveData.postValue(null);
lyricsLiveData.postValue(lyricsCache.getLyrics());
}
} else {
lyricsListLiveData.postValue(null);
lyricsLiveData.postValue(lyricsCache.getLyrics());
}
}
private void saveLyricsToCache(Child media, String lyrics, LyricsList lyricsList) {
if (media == null) {
return;
}
if ((lyricsList == null || !hasStructuredLyrics(lyricsList)) && TextUtils.isEmpty(lyrics)) {
return;
}
LyricsCache lyricsCache = new LyricsCache(media.getId());
lyricsCache.setArtist(media.getArtist());
lyricsCache.setTitle(media.getTitle());
lyricsCache.setUpdatedAt(System.currentTimeMillis());
if (lyricsList != null && hasStructuredLyrics(lyricsList)) {
lyricsCache.setStructuredLyrics(gson.toJson(lyricsList));
lyricsCache.setLyrics(null);
} else {
lyricsCache.setLyrics(lyrics);
lyricsCache.setStructuredLyrics(null);
}
lyricsRepository.insert(lyricsCache);
lyricsCachedLiveData.postValue(true);
}
private boolean hasStructuredLyrics(LyricsList lyricsList) {
return lyricsList != null
&& lyricsList.getStructuredLyrics() != null
&& !lyricsList.getStructuredLyrics().isEmpty()
&& lyricsList.getStructuredLyrics().get(0) != null
&& lyricsList.getStructuredLyrics().get(0).getLine() != null
&& !lyricsList.getStructuredLyrics().get(0).getLine().isEmpty();
}
private boolean shouldAutoDownloadLyrics() {
return Preferences.isAutoDownloadLyricsEnabled();
}
public boolean downloadCurrentLyrics() {
Child media = getLiveMedia().getValue();
if (media == null) {
return false;
}
LyricsList lyricsList = lyricsListLiveData.getValue();
String lyrics = lyricsLiveData.getValue();
if ((lyricsList == null || !hasStructuredLyrics(lyricsList)) && TextUtils.isEmpty(lyrics)) {
return false;
}
saveLyricsToCache(media, lyrics, lyricsList);
return true;
}
public LiveData<Boolean> getLyricsCachedState() {
return lyricsCachedLiveData;
}
public void changeSyncLyricsState() {
lyricsSyncState = !lyricsSyncState;
}

View File

@@ -1,6 +1,8 @@
package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
import android.app.Dialog;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
@@ -11,17 +13,17 @@ import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.repository.PlaylistRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Playlist;
import com.cappielloantonio.tempo.util.Preferences;
import com.google.common.collect.Lists;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class PlaylistChooserViewModel extends AndroidViewModel {
private final PlaylistRepository playlistRepository;
private final MutableLiveData<List<Playlist>> playlists = new MutableLiveData<>(null);
private ArrayList<Child> toAdd;
private ArrayList<Child> toAdd = new ArrayList<>();
public PlaylistChooserViewModel(@NonNull Application application) {
super(application);
@@ -34,8 +36,21 @@ public class PlaylistChooserViewModel extends AndroidViewModel {
return playlists;
}
public void addSongsToPlaylist(String playlistId) {
playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(Lists.transform(toAdd, Child::getId)));
public void addSongsToPlaylist(LifecycleOwner owner, Dialog dialog, String playlistId) {
List<String> songIds = Lists.transform(toAdd, Child::getId);
if (Preferences.allowPlaylistDuplicates()) {
playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds));
dialog.dismiss();
} else {
playlistRepository.getPlaylistSongs(playlistId).observe(owner, playlistSongs -> {
if (playlistSongs != null) {
List<String> playlistSongIds = Lists.transform(playlistSongs, Child::getId);
songIds.removeAll(playlistSongIds);
}
playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds));
dialog.dismiss();
});
}
}
public void setSongsToAdd(ArrayList<Child> songs) {

View File

@@ -109,7 +109,7 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
media.setStarred(new Date());
if (Preferences.isStarredSyncEnabled()) {
if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownload(media),
new Download(media)

View File

@@ -0,0 +1,90 @@
package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
import android.app.Activity;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.Child;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
public class StarredAlbumsSyncViewModel extends AndroidViewModel {
private final AlbumRepository albumRepository;
private final MutableLiveData<List<AlbumID3>> starredAlbums = new MutableLiveData<>(null);
private final MutableLiveData<List<Child>> starredAlbumSongs = new MutableLiveData<>(null);
public StarredAlbumsSyncViewModel(@NonNull Application application) {
super(application);
albumRepository = new AlbumRepository();
}
public LiveData<List<AlbumID3>> getStarredAlbums(LifecycleOwner owner) {
albumRepository.getStarredAlbums(false, -1).observe(owner, starredAlbums::postValue);
return starredAlbums;
}
public LiveData<List<Child>> getAllStarredAlbumSongs() {
albumRepository.getStarredAlbums(false, -1).observeForever(new Observer<List<AlbumID3>>() {
@Override
public void onChanged(List<AlbumID3> albums) {
if (albums != null && !albums.isEmpty()) {
collectAllAlbumSongs(albums, starredAlbumSongs::postValue);
} else {
starredAlbumSongs.postValue(new ArrayList<>());
}
albumRepository.getStarredAlbums(false, -1).removeObserver(this);
}
});
return starredAlbumSongs;
}
public LiveData<List<Child>> getStarredAlbumSongs(Activity activity) {
albumRepository.getStarredAlbums(false, -1).observe((LifecycleOwner) activity, albums -> {
if (albums != null && !albums.isEmpty()) {
collectAllAlbumSongs(albums, starredAlbumSongs::postValue);
} else {
starredAlbumSongs.postValue(new ArrayList<>());
}
});
return starredAlbumSongs;
}
private void collectAllAlbumSongs(List<AlbumID3> albums, AlbumSongsCallback callback) {
List<Child> allSongs = new ArrayList<>();
CountDownLatch latch = new CountDownLatch(albums.size());
for (AlbumID3 album : albums) {
LiveData<List<Child>> albumTracks = albumRepository.getAlbumTracks(album.getId());
albumTracks.observeForever(new Observer<List<Child>>() {
@Override
public void onChanged(List<Child> songs) {
if (songs != null) {
allSongs.addAll(songs);
}
latch.countDown();
if (latch.getCount() == 0) {
callback.onSongsCollected(allSongs);
albumTracks.removeObserver(this);
}
}
});
}
}
private interface AlbumSongsCallback {
void onSongsCollected(List<Child> songs);
}
}

View File

@@ -0,0 +1,94 @@
package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
import android.app.Activity;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.Child;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
public class StarredArtistsSyncViewModel extends AndroidViewModel {
private final ArtistRepository artistRepository;
private final MutableLiveData<List<ArtistID3>> starredArtists = new MutableLiveData<>(null);
private final MutableLiveData<List<Child>> starredArtistSongs = new MutableLiveData<>(null);
public StarredArtistsSyncViewModel(@NonNull Application application) {
super(application);
artistRepository = new ArtistRepository();
}
public LiveData<List<ArtistID3>> getStarredArtists(LifecycleOwner owner) {
artistRepository.getStarredArtists(false, -1).observe(owner, starredArtists::postValue);
return starredArtists;
}
public LiveData<List<Child>> getAllStarredArtistSongs() {
artistRepository.getStarredArtists(false, -1).observeForever(new Observer<List<ArtistID3>>() {
@Override
public void onChanged(List<ArtistID3> artists) {
if (artists != null && !artists.isEmpty()) {
collectAllArtistSongs(artists, starredArtistSongs::postValue);
} else {
starredArtistSongs.postValue(new ArrayList<>());
}
artistRepository.getStarredArtists(false, -1).removeObserver(this);
}
});
return starredArtistSongs;
}
public LiveData<List<Child>> getStarredArtistSongs(Activity activity) {
artistRepository.getStarredArtists(false, -1).observe((LifecycleOwner) activity, artists -> {
if (artists != null && !artists.isEmpty()) {
collectAllArtistSongs(artists, starredArtistSongs::postValue);
} else {
starredArtistSongs.postValue(new ArrayList<>());
}
});
return starredArtistSongs;
}
private void collectAllArtistSongs(List<ArtistID3> artists, ArtistSongsCallback callback) {
if (artists == null || artists.isEmpty()) {
callback.onSongsCollected(new ArrayList<>());
return;
}
List<Child> allSongs = new ArrayList<>();
AtomicInteger remainingArtists = new AtomicInteger(artists.size());
for (ArtistID3 artist : artists) {
artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() {
@Override
public void onSongsCollected(List<Child> songs) {
if (songs != null) {
allSongs.addAll(songs);
}
int remaining = remainingArtists.decrementAndGet();
if (remaining == 0) {
callback.onSongsCollected(allSongs);
}
}
});
}
}
private interface ArtistSongsCallback {
void onSongsCollected(List<Child> songs);
}
}

View File

@@ -0,0 +1,62 @@
package com.cappielloantonio.tempo.widget;
import android.content.ComponentName;
import android.content.Context;
import android.util.Log;
import androidx.media3.common.Player;
import androidx.media3.session.MediaController;
import androidx.media3.session.SessionToken;
import com.cappielloantonio.tempo.service.MediaService;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.concurrent.ExecutionException;
public final class WidgetActions {
public static void dispatchToMediaSession(Context ctx, String action) {
Log.d("TempoWidget", "dispatch action=" + action);
Context appCtx = ctx.getApplicationContext();
SessionToken token = new SessionToken(appCtx, new ComponentName(appCtx, MediaService.class));
ListenableFuture<MediaController> future = new MediaController.Builder(appCtx, token).buildAsync();
future.addListener(() -> {
try {
if (!future.isDone()) return;
MediaController c = future.get();
Log.d("TempoWidget", "controller connected, isPlaying=" + c.isPlaying());
switch (action) {
case WidgetProvider.ACT_PLAY_PAUSE:
if (c.isPlaying()) c.pause();
else c.play();
break;
case WidgetProvider.ACT_NEXT:
c.seekToNext();
break;
case WidgetProvider.ACT_PREV:
c.seekToPrevious();
break;
case WidgetProvider.ACT_TOGGLE_SHUFFLE:
c.setShuffleModeEnabled(!c.getShuffleModeEnabled());
break;
case WidgetProvider.ACT_CYCLE_REPEAT:
int repeatMode = c.getRepeatMode();
int nextMode;
if (repeatMode == Player.REPEAT_MODE_OFF) {
nextMode = Player.REPEAT_MODE_ALL;
} else if (repeatMode == Player.REPEAT_MODE_ALL) {
nextMode = Player.REPEAT_MODE_ONE;
} else {
nextMode = Player.REPEAT_MODE_OFF;
}
c.setRepeatMode(nextMode);
break;
}
WidgetUpdateManager.refreshFromController(ctx);
c.release();
} catch (ExecutionException | InterruptedException e) {
Log.e("TempoWidget", "dispatch failed", e);
}
}, MoreExecutors.directExecutor());
}
}

View File

@@ -0,0 +1,105 @@
package com.cappielloantonio.tempo.widget;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.content.Intent;
import android.widget.RemoteViews;
import com.cappielloantonio.tempo.R;
import android.app.TaskStackBuilder;
import android.app.PendingIntent;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import android.util.Log;
public class WidgetProvider extends AppWidgetProvider {
private static final String TAG = "TempoWidget";
public static final String ACT_PLAY_PAUSE = "tempo.widget.PLAY_PAUSE";
public static final String ACT_NEXT = "tempo.widget.NEXT";
public static final String ACT_PREV = "tempo.widget.PREV";
public static final String ACT_TOGGLE_SHUFFLE = "tempo.widget.SHUFFLE";
public static final String ACT_CYCLE_REPEAT = "tempo.widget.REPEAT";
@Override
public void onUpdate(Context ctx, AppWidgetManager mgr, int[] ids) {
for (int id : ids) {
RemoteViews rv = WidgetUpdateManager.chooseBuild(ctx, id);
attachIntents(ctx, rv, id);
mgr.updateAppWidget(id, rv);
}
}
@Override
public void onReceive(Context ctx, Intent intent) {
super.onReceive(ctx, intent);
String a = intent.getAction();
Log.d(TAG, "onReceive action=" + a);
if (ACT_PLAY_PAUSE.equals(a) || ACT_NEXT.equals(a) || ACT_PREV.equals(a)
|| ACT_TOGGLE_SHUFFLE.equals(a) || ACT_CYCLE_REPEAT.equals(a)) {
WidgetActions.dispatchToMediaSession(ctx, a);
} else if (AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(a)) {
WidgetUpdateManager.refreshFromController(ctx);
}
}
@Override
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, android.os.Bundle newOptions) {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
RemoteViews rv = WidgetUpdateManager.chooseBuild(context, appWidgetId);
attachIntents(context, rv, appWidgetId);
appWidgetManager.updateAppWidget(appWidgetId, rv);
WidgetUpdateManager.refreshFromController(context);
}
public static void attachIntents(Context ctx, RemoteViews rv) {
attachIntents(ctx, rv, 0);
}
public static void attachIntents(Context ctx, RemoteViews rv, int requestCodeBase) {
PendingIntent playPause = PendingIntent.getBroadcast(
ctx,
requestCodeBase + 0,
new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_PLAY_PAUSE),
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
);
PendingIntent next = PendingIntent.getBroadcast(
ctx,
requestCodeBase + 1,
new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_NEXT),
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
);
PendingIntent prev = PendingIntent.getBroadcast(
ctx,
requestCodeBase + 2,
new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_PREV),
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
);
PendingIntent shuffle = PendingIntent.getBroadcast(
ctx,
requestCodeBase + 3,
new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_TOGGLE_SHUFFLE),
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
);
PendingIntent repeat = PendingIntent.getBroadcast(
ctx,
requestCodeBase + 4,
new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_CYCLE_REPEAT),
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
);
rv.setOnClickPendingIntent(R.id.btn_play_pause, playPause);
rv.setOnClickPendingIntent(R.id.btn_next, next);
rv.setOnClickPendingIntent(R.id.btn_prev, prev);
rv.setOnClickPendingIntent(R.id.btn_shuffle, shuffle);
rv.setOnClickPendingIntent(R.id.btn_repeat, repeat);
PendingIntent launch = TaskStackBuilder.create(ctx)
.addNextIntentWithParentStack(new Intent(ctx, MainActivity.class))
.getPendingIntent(requestCodeBase + 10, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
rv.setOnClickPendingIntent(R.id.root, launch);
}
}

View File

@@ -0,0 +1,9 @@
package com.cappielloantonio.tempo.widget;
/**
* AppWidget provider entry for the 4x1 widget card. Inherits all behavior
* from {@link WidgetProvider}.
*/
public class WidgetProvider4x1 extends WidgetProvider {
}

View File

@@ -0,0 +1,276 @@
package com.cappielloantonio.tempo.widget;
import android.appwidget.AppWidgetManager;
import android.content.ComponentName;
import android.content.Context;
import android.graphics.Bitmap;
import android.text.TextUtils;
import android.graphics.drawable.Drawable;
import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.request.transition.Transition;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.R;
import androidx.media3.common.C;
import androidx.media3.session.MediaController;
import androidx.media3.session.SessionToken;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.concurrent.ExecutionException;
public final class WidgetUpdateManager {
public static void updateFromState(Context ctx,
String title,
String artist,
String album,
Bitmap art,
boolean playing,
boolean shuffleEnabled,
int repeatMode,
long positionMs,
long durationMs) {
if (TextUtils.isEmpty(title)) title = ctx.getString(R.string.widget_not_playing);
if (TextUtils.isEmpty(artist)) artist = ctx.getString(R.string.widget_placeholder_subtitle);
if (TextUtils.isEmpty(album)) album = "";
final TimingInfo timing = createTimingInfo(positionMs, durationMs);
AppWidgetManager mgr = AppWidgetManager.getInstance(ctx);
int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class));
for (int id : ids) {
android.widget.RemoteViews rv = choosePopulate(ctx, title, artist, album, art, playing,
timing.elapsedText, timing.totalText, timing.progress, shuffleEnabled, repeatMode, id);
WidgetProvider.attachIntents(ctx, rv, id);
mgr.updateAppWidget(id, rv);
}
}
public static void pushNow(Context ctx) {
AppWidgetManager mgr = AppWidgetManager.getInstance(ctx);
int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class));
for (int id : ids) {
android.widget.RemoteViews rv = chooseBuild(ctx, id);
WidgetProvider.attachIntents(ctx, rv, id);
mgr.updateAppWidget(id, rv);
}
}
public static void updateFromState(Context ctx,
String title,
String artist,
String album,
String coverArtId,
boolean playing,
boolean shuffleEnabled,
int repeatMode,
long positionMs,
long durationMs) {
final Context appCtx = ctx.getApplicationContext();
final String t = TextUtils.isEmpty(title) ? appCtx.getString(R.string.widget_not_playing) : title;
final String a = TextUtils.isEmpty(artist) ? appCtx.getString(R.string.widget_placeholder_subtitle) : artist;
final String alb = !TextUtils.isEmpty(album) ? album : "";
final boolean p = playing;
final boolean sh = shuffleEnabled;
final int rep = repeatMode;
final TimingInfo timing = createTimingInfo(positionMs, durationMs);
if (!TextUtils.isEmpty(coverArtId)) {
CustomGlideRequest.loadAlbumArtBitmap(
appCtx,
coverArtId,
com.cappielloantonio.tempo.util.Preferences.getImageSize(),
new CustomTarget<Bitmap>() {
@Override
public void onResourceReady(Bitmap resource, Transition<? super Bitmap> transition) {
AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx);
int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class));
for (int id : ids) {
android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, resource, p,
timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
WidgetProvider.attachIntents(appCtx, rv, id);
mgr.updateAppWidget(id, rv);
}
}
@Override
public void onLoadCleared(Drawable placeholder) {
AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx);
int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class));
for (int id : ids) {
android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p,
timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
WidgetProvider.attachIntents(appCtx, rv, id);
mgr.updateAppWidget(id, rv);
}
}
}
);
} else {
AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx);
int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class));
for (int id : ids) {
android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p,
timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
WidgetProvider.attachIntents(appCtx, rv, id);
mgr.updateAppWidget(id, rv);
}
}
}
public static void refreshFromController(Context ctx) {
final Context appCtx = ctx.getApplicationContext();
SessionToken token = new SessionToken(appCtx, new ComponentName(appCtx, MediaService.class));
ListenableFuture<MediaController> future = new MediaController.Builder(appCtx, token).buildAsync();
future.addListener(() -> {
try {
if (!future.isDone()) return;
MediaController c = future.get();
androidx.media3.common.MediaItem mi = c.getCurrentMediaItem();
String title = null, artist = null, album = null, coverId = null;
if (mi != null && mi.mediaMetadata != null) {
if (mi.mediaMetadata.title != null) title = mi.mediaMetadata.title.toString();
if (mi.mediaMetadata.artist != null)
artist = mi.mediaMetadata.artist.toString();
if (mi.mediaMetadata.albumTitle != null)
album = mi.mediaMetadata.albumTitle.toString();
if (mi.mediaMetadata.extras != null) {
if (title == null) title = mi.mediaMetadata.extras.getString("title");
if (artist == null) artist = mi.mediaMetadata.extras.getString("artist");
if (album == null) album = mi.mediaMetadata.extras.getString("album");
coverId = mi.mediaMetadata.extras.getString("coverArtId");
}
}
long position = c.getCurrentPosition();
long duration = c.getDuration();
if (position == C.TIME_UNSET) position = 0;
if (duration == C.TIME_UNSET) duration = 0;
updateFromState(appCtx,
title != null ? title : appCtx.getString(R.string.widget_not_playing),
artist != null ? artist : appCtx.getString(R.string.widget_placeholder_subtitle),
album,
coverId,
c.isPlaying(),
c.getShuffleModeEnabled(),
c.getRepeatMode(),
position,
duration);
c.release();
} catch (ExecutionException | InterruptedException ignored) {
}
}, MoreExecutors.directExecutor());
}
private static TimingInfo createTimingInfo(long positionMs, long durationMs) {
long safePosition = Math.max(0L, positionMs);
long safeDuration = durationMs > 0 ? durationMs : 0L;
if (safeDuration > 0 && safePosition > safeDuration) {
safePosition = safeDuration;
}
String elapsed = (safeDuration > 0 || safePosition > 0)
? MusicUtil.getReadableDurationString(safePosition, true)
: null;
String total = safeDuration > 0
? MusicUtil.getReadableDurationString(safeDuration, true)
: null;
int progress = 0;
if (safeDuration > 0) {
long scaled = safePosition * WidgetViewsFactory.PROGRESS_MAX;
long progressLong = scaled / safeDuration;
if (progressLong < 0) {
progress = 0;
} else if (progressLong > WidgetViewsFactory.PROGRESS_MAX) {
progress = WidgetViewsFactory.PROGRESS_MAX;
} else {
progress = (int) progressLong;
}
}
return new TimingInfo(elapsed, total, progress);
}
public static android.widget.RemoteViews chooseBuild(Context ctx, int appWidgetId) {
LayoutSize size = resolveLayoutSize(ctx, appWidgetId);
switch (size) {
case MEDIUM:
return WidgetViewsFactory.buildMedium(ctx);
case LARGE:
return WidgetViewsFactory.buildLarge(ctx);
case EXPANDED:
return WidgetViewsFactory.buildExpanded(ctx);
case COMPACT:
default:
return WidgetViewsFactory.buildCompact(ctx);
}
}
private static android.widget.RemoteViews choosePopulate(Context ctx,
String title,
String artist,
String album,
Bitmap art,
boolean playing,
String elapsedText,
String totalText,
int progress,
boolean shuffleEnabled,
int repeatMode,
int appWidgetId) {
LayoutSize size = resolveLayoutSize(ctx, appWidgetId);
switch (size) {
case MEDIUM:
return WidgetViewsFactory.populateMedium(ctx, title, artist, album, art, playing,
elapsedText, totalText, progress, shuffleEnabled, repeatMode);
case LARGE:
return WidgetViewsFactory.populateLarge(ctx, title, artist, album, art, playing,
elapsedText, totalText, progress, shuffleEnabled, repeatMode);
case EXPANDED:
return WidgetViewsFactory.populateExpanded(ctx, title, artist, album, art, playing,
elapsedText, totalText, progress, shuffleEnabled, repeatMode);
case COMPACT:
default:
return WidgetViewsFactory.populateCompact(ctx, title, artist, album, art, playing,
elapsedText, totalText, progress, shuffleEnabled, repeatMode);
}
}
private static LayoutSize resolveLayoutSize(Context ctx, int appWidgetId) {
AppWidgetManager mgr = AppWidgetManager.getInstance(ctx);
android.os.Bundle opts = mgr.getAppWidgetOptions(appWidgetId);
int minH = opts != null ? opts.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT) : 0;
int expandedThreshold = ctx.getResources().getInteger(R.integer.widget_expanded_min_height_dp);
int largeThreshold = ctx.getResources().getInteger(R.integer.widget_large_min_height_dp);
int mediumThreshold = ctx.getResources().getInteger(R.integer.widget_medium_min_height_dp);
if (minH >= expandedThreshold) return LayoutSize.EXPANDED;
if (minH >= largeThreshold) return LayoutSize.LARGE;
if (minH >= mediumThreshold) return LayoutSize.MEDIUM;
return LayoutSize.COMPACT;
}
private enum LayoutSize {
COMPACT,
MEDIUM,
LARGE,
EXPANDED
}
private static final class TimingInfo {
final String elapsedText;
final String totalText;
final int progress;
TimingInfo(String elapsedText, String totalText, int progress) {
this.elapsedText = elapsedText;
this.totalText = totalText;
this.progress = progress;
}
}
}

View File

@@ -0,0 +1,252 @@
package com.cappielloantonio.tempo.widget;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Shader;
import android.text.TextUtils;
import android.util.TypedValue;
import android.view.View;
import android.widget.RemoteViews;
import androidx.core.content.ContextCompat;
import androidx.media3.common.Player;
import com.cappielloantonio.tempo.R;
public final class WidgetViewsFactory {
static final int PROGRESS_MAX = 1000;
private static final float ALBUM_ART_CORNER_RADIUS_DP = 6f;
private WidgetViewsFactory() {
}
public static RemoteViews buildCompact(Context ctx) {
return build(ctx, R.layout.widget_layout_compact, false, false);
}
public static RemoteViews buildMedium(Context ctx) {
return build(ctx, R.layout.widget_layout_medium, false, false);
}
public static RemoteViews buildLarge(Context ctx) {
return build(ctx, R.layout.widget_layout_large_short, true, true);
}
public static RemoteViews buildExpanded(Context ctx) {
return build(ctx, R.layout.widget_layout_large, true, true);
}
private static RemoteViews build(Context ctx,
int layoutRes,
boolean showAlbum,
boolean showSecondaryControls) {
RemoteViews rv = new RemoteViews(ctx.getPackageName(), layoutRes);
rv.setTextViewText(R.id.title, ctx.getString(R.string.widget_not_playing));
rv.setTextViewText(R.id.subtitle, ctx.getString(R.string.widget_placeholder_subtitle));
rv.setTextViewText(R.id.album, "");
rv.setViewVisibility(R.id.album, showAlbum ? View.INVISIBLE : View.GONE);
rv.setTextViewText(R.id.time_elapsed, ctx.getString(R.string.widget_time_elapsed_placeholder));
rv.setTextViewText(R.id.time_total, ctx.getString(R.string.widget_time_duration_placeholder));
rv.setProgressBar(R.id.progress, PROGRESS_MAX, 0, false);
rv.setImageViewResource(R.id.btn_play_pause, R.drawable.ic_play);
rv.setImageViewResource(R.id.album_art, R.drawable.ic_splash_logo);
applySecondaryControlsDefaults(ctx, rv, showSecondaryControls);
return rv;
}
private static void applySecondaryControlsDefaults(Context ctx,
RemoteViews rv,
boolean show) {
int visibility = show ? View.VISIBLE : View.GONE;
rv.setViewVisibility(R.id.controls_secondary, visibility);
rv.setViewVisibility(R.id.btn_shuffle, visibility);
rv.setViewVisibility(R.id.btn_repeat, visibility);
if (show) {
int defaultColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint);
rv.setImageViewResource(R.id.btn_shuffle, R.drawable.ic_shuffle);
rv.setImageViewResource(R.id.btn_repeat, R.drawable.ic_repeat);
rv.setInt(R.id.btn_shuffle, "setColorFilter", defaultColor);
rv.setInt(R.id.btn_repeat, "setColorFilter", defaultColor);
}
}
public static RemoteViews populateCompact(Context ctx,
String title,
String subtitle,
String album,
Bitmap art,
boolean playing,
String elapsedText,
String totalText,
int progress,
boolean shuffleEnabled,
int repeatMode) {
return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
progress, R.layout.widget_layout_compact, false, false, shuffleEnabled, repeatMode);
}
public static RemoteViews populateMedium(Context ctx,
String title,
String subtitle,
String album,
Bitmap art,
boolean playing,
String elapsedText,
String totalText,
int progress,
boolean shuffleEnabled,
int repeatMode) {
return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
progress, R.layout.widget_layout_medium, true, true, shuffleEnabled, repeatMode);
}
public static RemoteViews populateLarge(Context ctx,
String title,
String subtitle,
String album,
Bitmap art,
boolean playing,
String elapsedText,
String totalText,
int progress,
boolean shuffleEnabled,
int repeatMode) {
return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
progress, R.layout.widget_layout_large_short, true, true, shuffleEnabled, repeatMode);
}
public static RemoteViews populateExpanded(Context ctx,
String title,
String subtitle,
String album,
Bitmap art,
boolean playing,
String elapsedText,
String totalText,
int progress,
boolean shuffleEnabled,
int repeatMode) {
return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
progress, R.layout.widget_layout_large, true, true, shuffleEnabled, repeatMode);
}
private static RemoteViews populateWithLayout(Context ctx,
String title,
String subtitle,
String album,
Bitmap art,
boolean playing,
String elapsedText,
String totalText,
int progress,
int layoutRes,
boolean showAlbum,
boolean showSecondaryControls,
boolean shuffleEnabled,
int repeatMode) {
RemoteViews rv = new RemoteViews(ctx.getPackageName(), layoutRes);
rv.setTextViewText(R.id.title, title);
rv.setTextViewText(R.id.subtitle, subtitle);
if (showAlbum && !TextUtils.isEmpty(album)) {
rv.setTextViewText(R.id.album, album);
rv.setViewVisibility(R.id.album, View.VISIBLE);
} else {
rv.setTextViewText(R.id.album, "");
rv.setViewVisibility(R.id.album, View.GONE);
}
if (art != null) {
Bitmap rounded = maybeRoundBitmap(ctx, art);
rv.setImageViewBitmap(R.id.album_art, rounded != null ? rounded : art);
} else {
rv.setImageViewResource(R.id.album_art, R.drawable.ic_splash_logo);
}
rv.setImageViewResource(R.id.btn_play_pause,
playing ? R.drawable.ic_pause : R.drawable.ic_play);
String elapsed = !TextUtils.isEmpty(elapsedText)
? elapsedText
: ctx.getString(R.string.widget_time_elapsed_placeholder);
String total = !TextUtils.isEmpty(totalText)
? totalText
: ctx.getString(R.string.widget_time_duration_placeholder);
int safeProgress = progress;
if (safeProgress < 0) safeProgress = 0;
if (safeProgress > PROGRESS_MAX) safeProgress = PROGRESS_MAX;
rv.setTextViewText(R.id.time_elapsed, elapsed);
rv.setTextViewText(R.id.time_total, total);
rv.setProgressBar(R.id.progress, PROGRESS_MAX, safeProgress, false);
applySecondaryControls(ctx, rv, showSecondaryControls, shuffleEnabled, repeatMode);
return rv;
}
private static Bitmap maybeRoundBitmap(Context ctx, Bitmap source) {
if (source == null || source.isRecycled()) {
return null;
}
try {
int width = source.getWidth();
int height = source.getHeight();
if (width <= 0 || height <= 0) {
return null;
}
Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(output);
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setShader(new BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
float radiusPx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
ALBUM_ART_CORNER_RADIUS_DP,
ctx.getResources().getDisplayMetrics());
float maxRadius = Math.min(width, height) / 2f;
float safeRadius = Math.min(radiusPx, maxRadius);
canvas.drawRoundRect(new RectF(0f, 0f, width, height), safeRadius, safeRadius, paint);
return output;
} catch (RuntimeException | OutOfMemoryError e) {
android.util.Log.w("TempoWidget", "Failed to round album art", e);
return null;
}
}
private static void applySecondaryControls(Context ctx,
RemoteViews rv,
boolean show,
boolean shuffleEnabled,
int repeatMode) {
if (!show) {
rv.setViewVisibility(R.id.controls_secondary, View.GONE);
rv.setViewVisibility(R.id.btn_shuffle, View.GONE);
rv.setViewVisibility(R.id.btn_repeat, View.GONE);
return;
}
int inactiveColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint);
int activeColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint_active);
rv.setViewVisibility(R.id.controls_secondary, View.VISIBLE);
rv.setViewVisibility(R.id.btn_shuffle, View.VISIBLE);
rv.setViewVisibility(R.id.btn_repeat, View.VISIBLE);
rv.setImageViewResource(R.id.btn_shuffle, R.drawable.ic_shuffle);
rv.setImageViewResource(R.id.btn_repeat,
repeatMode == Player.REPEAT_MODE_ONE ? R.drawable.ic_repeat_one : R.drawable.ic_repeat);
rv.setInt(R.id.btn_shuffle, "setColorFilter", shuffleEnabled ? activeColor : inactiveColor);
rv.setInt(R.id.btn_repeat, "setColorFilter",
repeatMode == Player.REPEAT_MODE_OFF ? inactiveColor : activeColor);
}
}

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:autoMirrored="true">
<path
android:fillColor="@color/titleTextColor"
android:pathData="M160,800L160,480L320,480L320,800L160,800ZM400,800L400,160L560,160L560,800L400,800ZM640,800L640,360L800,360L800,800L640,800Z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/titleTextColor"
android:pathData="M10,4L12,6H20c1.1,0 2,0.9 2,2v10c0,1.1 -0.9,2 -2,2H4c-1.1,0 -2,-0.9 -2,-2V6c0,-1.1 0.9,-2 2,-2h6z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/titleTextColor"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -8,3.58 -8,8h2c0,-3.31 2.69,-6 6,-6 1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35zM19,12c0,3.31 -2.69,6 -6,6 -1.66,0 -3.14,-0.69 -4.22,-1.78L11,13H4v7l2.35,-2.35C7.8,19.1 9.79,20 12,20c4.42,0 8,-3.58 8,-8h-2z" />
</vector>

View File

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/titleTextColor"
android:pathData="M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17H7v-3l-4,4 4,4v-3h12v-6h-2v4z" />
<path
android:fillColor="@color/titleTextColor"
android:pathData="M12,9h-2v2h1v6h2V9h-1z" />
</vector>

View File

@@ -0,0 +1,93 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="757.96dp"
android:height="743.73dp"
android:viewportWidth="757.96"
android:viewportHeight="743.73">
<path
android:pathData="M91.45,0a32.04,32.04 0,0 0,-32 32L59.45,710.43a32.04,32.04 0,0 0,32 32h297a32.04,32.04 0,0 0,32 -32L420.45,32a32.04,32.04 0,0 0,-32 -32Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M400.66,156.98v-54.44a125.25,125.25 0,0 1,-80.86 -60.19h0a23.79,23.79 0,0 1,-14.22 4.68L262.35,47.03A178.55,178.55 0,0 0,400.66 156.98Z"
android:fillColor="#fff"/>
<path
android:pathData="M400.66,99.42v-52.3a29.12,29.12 0,0 0,-29.13 -29.13h-41.97v5.05a23.92,23.92 0,0 1,-7.4 17.33,122.3 122.3,0 0,0 78.5,59.05Z"
android:fillColor="#fff"/>
<path
android:pathData="M198.77,47.03L171.74,47.03a23.99,23.99 0,0 1,-23.98 -23.99v-5.05L108.38,17.99a29.13,29.13 0,0 0,-29.13 29.13v648.2a29.08,29.08 0,0 0,29.13 29.11h263.15a28.36,28.36 0,0 0,3.59 -0.22,29.15 29.15,0 0,0 25.54,-28.89L400.66,218.15C304.95,207.07 225.2,138.77 198.77,47.03Z"
android:fillColor="#fff"/>
<path
android:pathData="M259.07,47.03h-57.14c26.3,90.04 104.68,157.03 198.73,168.07v-55.02A181.67,181.67 0,0 1,259.07 47.03Z"
android:fillColor="#fff"/>
<path
android:pathData="M380.61,532.78h-270a5.01,5.01 0,0 1,-5 -5L105.61,460.81a5.01,5.01 0,0 1,5 -5h270a5.01,5.01 0,0 1,5 5v66.98A5.01,5.01 0,0 1,380.61 532.78ZM110.61,457.8a3,3 0,0 0,-3 3v66.98a3,3 0,0 0,3 3h270a3,3 0,0 0,3 -3L383.61,460.81a3,3 0,0 0,-3 -3Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M145.61,494.29m-21,0a21,21 0,1 1,42 0a21,21 0,1 1,-42 0"
android:fillColor="#3f3d56"/>
<path
android:pathData="M194.11,480.29a3.5,3.5 0,0 0,0 7h165a3.5,3.5 0,1 0,0 -7Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M194.11,501.29a3.5,3.5 0,0 0,0 7h165a3.5,3.5 0,1 0,0 -7Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M380.61,644.78h-270a5.01,5.01 0,0 1,-5 -5L105.61,572.81a5.01,5.01 0,0 1,5 -5h270a5.01,5.01 0,0 1,5 5v66.98A5.01,5.01 0,0 1,380.61 644.78ZM110.61,569.8a3,3 0,0 0,-3 3v66.98a3,3 0,0 0,3 3h270a3,3 0,0 0,3 -3L383.61,572.81a3,3 0,0 0,-3 -3Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M145.61,606.29m-21,0a21,21 0,1 1,42 0a21,21 0,1 1,-42 0"
android:fillColor="#3f3d56"/>
<path
android:pathData="M194.11,592.29a3.5,3.5 0,0 0,0 7h165a3.5,3.5 0,1 0,0 -7Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M194.11,613.29a3.5,3.5 0,0 0,0 7h165a3.5,3.5 0,1 0,0 -7Z"
android:fillColor="#e6e6e6"/>
<path
android:pathData="M239.93,394a94.96,94.96 0,0 1,-95 -95c0,-0.2 0,-0.41 0.01,-0.61 0.29,-52.03 42.9,-94.39 94.99,-94.39a95,95 0,1 1,0 190ZM239.93,206a93.2,93.2 0,0 0,-92.99 92.46c-0.01,0.21 -0.01,0.38 -0.01,0.54a93.01,93.01 0,1 0,93 -93Z"
android:fillColor="#3f3d56"/>
<path
android:pathData="M282.95,296.81l-65.02,-37.54a2,2 0,0 0,-3 1.73L214.93,336.08a2,2 0,0 0,3 1.73l65.02,-37.54a2,2 0,0 0,0 -3.46l-65.02,-37.54a2,2 0,0 0,-3 1.73L214.93,336.08a2,2 0,0 0,3 1.73l65.02,-37.54a2,2 0,0 0,0 -3.46Z"
android:fillColor="#6c63ff"/>
<path
android:pathData="M757.57,743.73H0v-2.18H757.96Z"
android:fillColor="#3f3d56"/>
<path
android:pathData="M590.68,338.14m-27.94,0a27.94,27.94 0,1 1,55.87 0a27.94,27.94 0,1 1,-55.87 0"
android:fillColor="#ffb8b8"/>
<path
android:pathData="M588.87,494.75a12.51,12.51 0,0 1,9.47 -16.1,11.89 11.89,0 0,1 1.66,-0.2l29.43,-47.23L602.55,405.66A10.73,10.73 0,1 1,617.47 390.25l37.11,36.6 0.08,0.09a9.72,9.72 0,0 1,-0.68 11.58L612.75,487.28a11.73,11.73 0,0 1,0.31 1.19,12.51 12.51,0 0,1 -11.23,14.92q-0.53,0.05 -1.06,0.05A12.55,12.55 0,0 1,588.87 494.75Z"
android:fillColor="#ffb8b8"/>
<path
android:pathData="M544.67,726.93L530.72,726.93l-6.63,-53.79 20.58,0Z"
android:fillColor="#ffb8b8"/>
<path
android:pathData="M548.79,741.02l-46.1,0L502.69,739.88a18.07,18.07 0,0 1,18.07 -18.07h28.03Z"
android:fillColor="#2f2e41"/>
<path
android:pathData="M683.27,707.66l-11.98,7.14 -33.22,-42.82 17.68,-10.53Z"
android:fillColor="#ffb8b8"/>
<path
android:pathData="M654.41,741.23l-0.58,-0.98a18.07,18.07 0,0 1,6.28 -24.77l24.08,-14.34 9.83,16.5Z"
android:fillColor="#2f2e41"/>
<path
android:pathData="M522.33,703.25c-9.34,-109.99 -14.9,-212.18 19.25,-253.86l0.26,-0.32 57.47,22.99 0.09,0.2c0.19,0.42 19.31,42.46 14.85,70.74l14.18,65.21 46.22,77.39a5.12,5.12 0,0 1,-2.33 7.31l-20.09,8.84a5.14,5.14 0,0 1,-6.42 -2.01L595.53,617.75l-28.4,-62.88a1.71,1.71 0,0 0,-3.25 0.52L548.14,703.36a5.11,5.11 0,0 1,-5.09 4.58L527.43,707.94A5.15,5.15 0,0 1,522.33 703.25Z"
android:fillColor="#2f2e41"/>
<path
android:pathData="M541.77,450.26l-0.27,-0.13 -0.04,-0.3c-2.15,-15.02 0.39,-31.72 7.55,-49.62a39.4,39.4 0,0 1,45.73 -23.59h0a39.35,39.35 0,0 1,25.09 19.3,38.92 38.92,0 0,1 2.7,31.19c-9.02,26.39 -20.73,51.08 -20.85,51.32l-0.25,0.51Z"
android:fillColor="#6c63ff"/>
<path
android:pathData="M500.42,512.57a12.78,12.78 0,0 1,9.16 -13.94l53.74,-103.17a10.3,10.3 0,1 1,17.52 10.82L525.84,508.73a12.42,12.42 0,0 1,0.2 1.89,12.86 12.86,0 0,1 -13.03,13.21h0a12.87,12.87 0,0 1,-9.87 -4.83,12.71 12.71,0 0,1 -2.71,-6.43Z"
android:fillColor="#ffb8b8"/>
<path
android:pathData="M556.81,322.35h44.36L601.16,303.02c-9.74,-3.87 -19.26,-7.16 -25.02,0a19.34,19.34 0,0 0,-19.34 19.34Z"
android:fillColor="#2f2e41"/>
<path
android:pathData="M603.62,299.61c26.52,0 33.94,33.24 33.94,51.99 0,10.46 -4.73,14.2 -12.16,15.46l-2.63,-14 -6.15,14.6c-2.09,0.01 -4.28,-0.03 -6.55,-0.07l-2.08,-4.29 -4.65,4.22c-18.62,0.03 -33.66,2.74 -33.66,-15.92C569.68,332.85 576.19,299.61 603.62,299.61Z"
android:fillColor="#2f2e41"/>
<path
android:pathData="M595.72,327L595.72,301.13a2.33,2.33 0,0 0,-2.33 -2.33h-4.67a2.33,2.33 0,0 0,-2.33 2.33L586.39,325.44a14.74,14.74 0,1 0,9.33 1.56Z"
android:fillColor="#6c63ff"/>
<path
android:pathData="M589.5,340.01m-7,0a7,7 0,1 1,14 0a7,7 0,1 1,-14 0"
android:fillColor="#fff"/>
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="10dp" />
<solid android:color="@color/widget_bg" />
</shape>

View File

@@ -75,6 +75,39 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/rating_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="0dp"
android:orientation="horizontal"
android:gravity="center"
android:scaleX="0.8"
android:scaleY="0.8"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/vertical_guideline"
app:layout_constraintTop_toBottomOf="@+id/player_media_quality_sector">
<RatingBar
android:id="@+id/song_rating_bar"
style="?android:attr/ratingBarStyleIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:numStars="5"
android:stepSize="1"
android:rating="0"
android:isIndicator="false" />
<TextView
android:id="@+id/rating_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:text=""/>
</LinearLayout>
<TextView
android:id="@+id/player_media_title_label"
style="@style/HeadlineLarge"
@@ -349,11 +382,23 @@
android:layout_height="wrap_content"
android:padding="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintStart_toEndOf="@+id/player_open_equalizer_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:srcCompat="@drawable/ic_queue" />
<ImageButton
android:id="@+id/player_open_equalizer_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/player_open_queue_button"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:srcCompat="@drawable/ic_eq" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -47,6 +47,8 @@
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/activity_info_offline_mode"
android:textSize="6sp"
android:textSize="12sp"
android:textStyle="bold"
android:visibility="gone" />
</LinearLayout>

View File

@@ -19,7 +19,8 @@
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/playlist_dialog_recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="8dp"
android:clipToPadding="false" />
</LinearLayout>

View File

@@ -0,0 +1,14 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="24dp"
android:layout_marginBottom="4dp"
android:text="@string/starred_album_sync_dialog_summary" />
</LinearLayout>

View File

@@ -0,0 +1,14 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="24dp"
android:layout_marginBottom="4dp"
android:text="@string/starred_artist_sync_dialog_summary" />
</LinearLayout>

View File

@@ -49,8 +49,9 @@
<TextView
android:id="@+id/subtitle_empty_description_label"
style="@style/LabelSmall"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:paddingStart="56dp"
android:paddingEnd="56dp"
android:text="@string/download_info_empty_subtitle" />
@@ -79,7 +80,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/download_title_section"
app:layout_constraintEnd_toStartOf="@+id/downloaded_go_back_image_view"
app:layout_constraintEnd_toStartOf="@+id/downloaded_refresh_image_view"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@@ -93,6 +94,19 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/downloaded_text_view_refreshable"/>
<ImageView
android:id="@+id/downloaded_refresh_image_view"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:background="@drawable/ic_refresh"
android:contentDescription="@string/download_refresh_button_content_description"
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
app:layout_constraintEnd_toStartOf="@id/downloaded_go_back_image_view"
app:layout_constraintStart_toEndOf="@id/downloaded_text_view_refreshable"
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
<ImageView
android:id="@+id/downloaded_go_back_image_view"
android:layout_width="24dp"
@@ -102,6 +116,7 @@
android:background="@drawable/ic_arrow_back"
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
app:layout_constraintEnd_toStartOf="@id/downloaded_group_by_image_view"
app:layout_constraintStart_toEndOf="@id/downloaded_refresh_image_view"
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
<ImageView

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/eq_frame_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:id="@+id/eq_scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:padding="16dp">
<LinearLayout
android:id="@+id/eq_root_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/equalizer_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/equalizer_fragment_title"
style="@style/HeadlineSmall"
android:layout_gravity="center_horizontal"
android:paddingBottom="16dp" />
<LinearLayout
android:id="@+id/equalizer_switch_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingBottom="16dp">
<TextView
android:id="@+id/equalizer_switch_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
style="@style/LabelMedium"
android:text="@string/equalizer_enable" />
<Switch
android:id="@+id/equalizer_switch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>
<LinearLayout
android:id="@+id/eq_bands_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
</LinearLayout>
<Button
android:id="@+id/equalizer_reset_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/equalizer_reset"
android:layout_gravity="center_horizontal"
style="@style/Widget.Material3.Button.TextButton"
android:layout_marginTop="24dp"/>
<Space
android:id="@+id/equalizer_bottom_space"
android:layout_width="match_parent"
android:layout_height="128dp"
android:layout_marginTop="0dp" />
</LinearLayout>
</ScrollView>
<LinearLayout
android:id="@+id/equalizer_not_supported_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:layout_gravity="center"
android:visibility="gone">
<ImageView
android:id="@+id/equalizer_not_supported_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:maxWidth="240dp"
android:maxHeight="240dp"
android:scaleType="centerInside"
android:src="@drawable/ui_eq_not_supported" />
<TextView
android:id="@+id/equalizer_not_supported_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/equalizer_not_supported"
android:gravity="center"
style="@style/BodyMedium"
android:layout_marginTop="16dp"/>
</LinearLayout>
</FrameLayout>

View File

@@ -106,6 +106,190 @@
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Download/Sync starred albums -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/home_sync_starred_albums_card"
style="?attr/materialCardViewOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="24dp"
android:visibility="gone">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="20dp"
android:paddingVertical="12dp">
<!-- Title, secondary and supporting text -->
<TextView
android:id="@+id/home_sync_starred_albums_title"
style="@style/TitleLarge"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/home_sync_starred_albums_title"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textFontWeight="600"
app:layout_constraintEnd_toStartOf="@id/vertical_guideline_albums"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/home_sync_starred_albums_subtitle"
style="@style/TitleMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/home_sync_starred_albums_subtitle"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/home_sync_starred_albums_title" />
<TextView
android:id="@+id/home_sync_starred_albums_to_sync"
style="@style/TitleSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:text="@string/home_sync_starred_albums_subtitle"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/home_sync_starred_albums_subtitle" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="end"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/home_sync_starred_albums_to_sync">
<com.google.android.material.button.MaterialButton
android:id="@+id/home_sync_starred_albums_cancel"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/home_sync_starred_cancel" />
<com.google.android.material.button.MaterialButton
android:id="@+id/home_sync_starred_albums_download"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/home_sync_starred_download" />
</LinearLayout>
<androidx.constraintlayout.widget.Guideline
android:id="@+id/vertical_guideline_albums"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.90" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Download/Sync starred artists -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/home_sync_starred_artists_card"
style="?attr/materialCardViewOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="24dp"
android:visibility="gone">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="20dp"
android:paddingVertical="12dp">
<!-- Title, secondary and supporting text -->
<TextView
android:id="@+id/home_sync_starred_artists_title"
style="@style/TitleLarge"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/home_sync_starred_artists_title"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textFontWeight="600"
app:layout_constraintEnd_toStartOf="@id/vertical_guideline_artists"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/home_sync_starred_artists_subtitle"
style="@style/TitleMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/home_sync_starred_artists_subtitle"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/home_sync_starred_artists_title" />
<TextView
android:id="@+id/home_sync_starred_artists_to_sync"
style="@style/TitleSmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="16dp"
android:text="@string/home_sync_starred_artists_subtitle"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/home_sync_starred_artists_subtitle" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="end"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/home_sync_starred_artists_to_sync">
<com.google.android.material.button.MaterialButton
android:id="@+id/home_sync_starred_artists_cancel"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/home_sync_starred_cancel" />
<com.google.android.material.button.MaterialButton
android:id="@+id/home_sync_starred_artists_download"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/home_sync_starred_download" />
</LinearLayout>
<androidx.constraintlayout.widget.Guideline
android:id="@+id/vertical_guideline_artists"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.90" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Discover music -->
<LinearLayout
android:id="@+id/home_discover_sector"

View File

@@ -81,14 +81,14 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="24dp"
android:ellipsize="marquee"
android:singleLine="true"
android:text="@string/label_placeholder"
app:layout_constraintEnd_toStartOf="@+id/button_favorite"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/guideline" />
app:layout_constraintTop_toBottomOf="@+id/rating_container" />
<TextView
android:id="@+id/player_artist_name_label"
@@ -104,6 +104,39 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/player_media_title_label" />
<LinearLayout
android:id="@+id/rating_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="0dp"
android:orientation="horizontal"
android:gravity="center"
android:scaleX="0.8"
android:scaleY="0.8"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/guideline">
<RatingBar
android:id="@+id/song_rating_bar"
style="?android:attr/ratingBarStyleIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:numStars="5"
android:stepSize="1"
android:rating="0"
android:isIndicator="false" />
<TextView
android:id="@+id/rating_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:text=""/>
</LinearLayout>
<ToggleButton
android:id="@+id/button_favorite"
android:layout_width="26dp"
@@ -136,7 +169,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="20dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
app:bar_height="2dp"
app:buffered_color="?attr/colorOnSecondaryContainer"
@@ -348,11 +381,23 @@
android:layout_height="wrap_content"
android:padding="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintStart_toEndOf="@+id/player_open_equalizer_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:srcCompat="@drawable/ic_queue" />
<ImageButton
android:id="@+id/player_open_equalizer_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/player_open_queue_button"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:srcCompat="@drawable/ic_eq" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -51,7 +51,25 @@
app:layout_constraintTop_toTopOf="parent" />
</androidx.core.widget.NestedScrollView>
<Button
<com.google.android.material.button.MaterialButton
android:id="@+id/download_lyrics_button"
style="@style/Widget.Material3.Button.TonalButton.Icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="16dp"
android:alpha="0.7"
android:contentDescription="@string/player_lyrics_download_content_description"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:visibility="gone"
app:cornerRadius="64dp"
app:icon="@drawable/ic_download"
app:layout_constraintBottom_toTopOf="@+id/sync_lyrics_tap_button"
app:layout_constraintEnd_toEndOf="@+id/now_playing_song_lyrics_sroll_view" />
<com.google.android.material.button.MaterialButton
android:id="@+id/sync_lyrics_tap_button"
style="@style/Widget.Material3.Button.TonalButton.Icon"
android:layout_width="48dp"

View File

@@ -55,6 +55,27 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/different_disk_divider_sector" />
<View
android:id="@+id/cover_art_overlay"
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_marginStart="16dp"
android:background="#80000000"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/song_cover_image_view"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/different_disk_divider_sector" />
<ImageView
android:id="@+id/play_pause_icon"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="center"
android:layout_marginStart="28dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/different_disk_divider_sector" />
<TextView
android:id="@+id/track_number_text_view"
style="@style/LabelLarge"

View File

@@ -20,6 +20,27 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/cover_art_overlay"
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_marginStart="2dp"
android:background="#80000000"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/play_pause_icon"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="center"
android:layout_margin="14dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/queue_song_title_text_view"
style="@style/LabelMedium"

View File

@@ -0,0 +1,175 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="64dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:background="@drawable/widget_bg">
<ImageView
android:id="@+id/album_art"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_centerVertical="true"
android:scaleType="centerCrop"
android:contentDescription="@string/widget_content_desc_album_art"/>
<LinearLayout
android:id="@+id/texts"
android:orientation="vertical"
android:layout_toRightOf="@id/album_art"
android:layout_toEndOf="@id/album_art"
android:layout_toLeftOf="@id/controls"
android:layout_toStartOf="@id/controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="8dp">
<TextView
android:id="@+id/title"
android:maxLines="1"
android:ellipsize="end"
android:textStyle="bold"
android:textSize="14sp"
android:textColor="@color/widget_title"
android:includeFontPadding="false"
android:freezesText="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/subtitle"
android:maxLines="1"
android:ellipsize="end"
android:textSize="12sp"
android:textColor="@color/widget_subtitle"
android:includeFontPadding="false"
android:freezesText="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/album"
android:maxLines="1"
android:ellipsize="end"
android:textSize="11sp"
android:textColor="@color/widget_subtitle"
android:includeFontPadding="false"
android:freezesText="true"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:visibility="gone"/>
<ProgressBar
android:id="@+id/progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginTop="2dp"
android:indeterminate="false"
android:max="1000"
android:progress="0"
android:progressBackgroundTint="@color/widget_subtitle"
android:progressTint="@color/widget_icon_tint"/>
<LinearLayout
android:id="@+id/timing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:orientation="horizontal">
<TextView
android:id="@+id/time_elapsed"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/widget_time_elapsed_placeholder"
android:textColor="@color/widget_subtitle"
android:textSize="10sp"
android:includeFontPadding="false"/>
<TextView
android:id="@+id/time_total"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:text="@string/widget_time_duration_placeholder"
android:textColor="@color/widget_subtitle"
android:textSize="10sp"
android:includeFontPadding="false"/>
</LinearLayout>
<LinearLayout
android:id="@+id/controls_secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginTop="4dp"
android:visibility="gone">
<ImageButton
android:id="@+id/btn_shuffle"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_shuffle"
android:src="@drawable/ic_shuffle"
android:tint="@color/widget_icon_tint"/>
<ImageButton
android:id="@+id/btn_repeat"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="4dp"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_repeat"
android:src="@drawable/ic_repeat"
android:tint="@color/widget_icon_tint"/>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/controls"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true"
android:orientation="horizontal"
android:gravity="center"
android:layout_width="wrap_content"
android:layout_height="match_parent">
<ImageButton
android:id="@+id/btn_prev"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_skip_previous"
android:contentDescription="@string/widget_content_desc_prev"
android:tint="@color/widget_icon_tint"/>
<ImageButton
android:id="@+id/btn_play_pause"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_play"
android:contentDescription="@string/widget_content_desc_play_pause"
android:tint="@color/widget_icon_tint"/>
<ImageButton
android:id="@+id/btn_next"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_skip_next"
android:contentDescription="@string/widget_content_desc_next"
android:tint="@color/widget_icon_tint"/>
</LinearLayout>
</RelativeLayout>

View File

@@ -0,0 +1,189 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:minHeight="200dp"
android:orientation="vertical"
android:padding="16dp"
android:background="@drawable/widget_bg">
<LinearLayout
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:baselineAligned="false">
<ImageView
android:id="@+id/album_art"
android:layout_width="150dp"
android:layout_height="150dp"
android:scaleType="centerCrop"
android:contentDescription="@string/widget_content_desc_album_art" />
<LinearLayout
android:id="@+id/text_container"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center_vertical">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:textStyle="bold"
android:textSize="18sp"
android:textColor="@color/widget_title"
android:includeFontPadding="false"
android:freezesText="true" />
<TextView
android:id="@+id/subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:singleLine="true"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:textSize="14sp"
android:textColor="@color/widget_subtitle"
android:includeFontPadding="false"
android:freezesText="true" />
<TextView
android:id="@+id/album"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:singleLine="true"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:textSize="13sp"
android:textColor="@color/widget_subtitle"
android:includeFontPadding="false"
android:freezesText="true"
android:visibility="invisible" />
</LinearLayout>
</LinearLayout>
<ProgressBar
android:id="@+id/progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="6dp"
android:layout_marginTop="16dp"
android:indeterminate="false"
android:max="1000"
android:progress="0"
android:progressBackgroundTint="@color/widget_subtitle"
android:progressTint="@color/widget_icon_tint" />
<LinearLayout
android:id="@+id/timing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<TextView
android:id="@+id/time_elapsed"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/widget_time_elapsed_placeholder"
android:textColor="@color/widget_subtitle"
android:textSize="12sp" />
<TextView
android:id="@+id/time_total"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:text="@string/widget_time_duration_placeholder"
android:textColor="@color/widget_subtitle"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginBottom="4dp"
android:gravity="center"
android:orientation="horizontal">
<ImageButton
android:id="@+id/btn_prev"
android:layout_width="0dp"
android:layout_height="52dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_prev"
android:src="@drawable/ic_skip_previous"
android:tint="@color/widget_icon_tint" />
<ImageButton
android:id="@+id/btn_play_pause"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_play_pause"
android:src="@drawable/ic_play"
android:tint="@color/widget_icon_tint" />
<ImageButton
android:id="@+id/btn_next"
android:layout_width="0dp"
android:layout_height="52dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_next"
android:src="@drawable/ic_skip_next"
android:tint="@color/widget_icon_tint" />
</LinearLayout>
<LinearLayout
android:id="@+id/controls_secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<ImageButton
android:id="@+id/btn_shuffle"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_shuffle"
android:src="@drawable/ic_shuffle"
android:tint="@color/widget_icon_tint" />
<ImageButton
android:id="@+id/btn_repeat"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_repeat"
android:src="@drawable/ic_repeat"
android:tint="@color/widget_icon_tint" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,198 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:minHeight="172dp"
android:padding="16dp"
android:orientation="vertical"
android:background="@drawable/widget_bg">
<LinearLayout
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal"
android:baselineAligned="false"
android:gravity="center_vertical">
<FrameLayout
android:id="@+id/album_art_container"
android:layout_width="90dp"
android:layout_height="90dp"
android:layout_gravity="center_vertical">
<ImageView
android:id="@+id/album_art"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:contentDescription="@string/widget_content_desc_album_art" />
</FrameLayout>
<LinearLayout
android:id="@+id/text_container"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center_vertical">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:textStyle="bold"
android:textSize="18sp"
android:textColor="@color/widget_title"
android:includeFontPadding="false"
android:freezesText="true" />
<TextView
android:id="@+id/subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:singleLine="true"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:textSize="14sp"
android:textColor="@color/widget_subtitle"
android:includeFontPadding="false"
android:freezesText="true" />
<TextView
android:id="@+id/album"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:singleLine="true"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:textSize="13sp"
android:textColor="@color/widget_subtitle"
android:includeFontPadding="false"
android:freezesText="true"
android:visibility="invisible" />
</LinearLayout>
</LinearLayout>
<ProgressBar
android:id="@+id/progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="6dp"
android:layout_marginTop="12dp"
android:indeterminate="false"
android:max="1000"
android:progress="0"
android:progressBackgroundTint="@color/widget_subtitle"
android:progressTint="@color/widget_icon_tint" />
<LinearLayout
android:id="@+id/timing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:orientation="horizontal">
<TextView
android:id="@+id/time_elapsed"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/widget_time_elapsed_placeholder"
android:textColor="@color/widget_subtitle"
android:textSize="12sp" />
<TextView
android:id="@+id/time_total"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:text="@string/widget_time_duration_placeholder"
android:textColor="@color/widget_subtitle"
android:textSize="12sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/controls_secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageButton
android:id="@+id/btn_shuffle"
android:layout_width="0dp"
android:layout_height="46dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_shuffle"
android:src="@drawable/ic_shuffle"
android:tint="@color/widget_icon_tint" />
<LinearLayout
android:id="@+id/controls"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:layout_weight="3"
android:gravity="center"
android:orientation="horizontal">
<ImageButton
android:id="@+id/btn_prev"
android:layout_width="0dp"
android:layout_height="46dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_prev"
android:src="@drawable/ic_skip_previous"
android:tint="@color/widget_icon_tint" />
<ImageButton
android:id="@+id/btn_play_pause"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_play_pause"
android:src="@drawable/ic_play"
android:tint="@color/widget_icon_tint" />
<ImageButton
android:id="@+id/btn_next"
android:layout_width="0dp"
android:layout_height="46dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_next"
android:src="@drawable/ic_skip_next"
android:tint="@color/widget_icon_tint" />
</LinearLayout>
<ImageButton
android:id="@+id/btn_repeat"
android:layout_width="0dp"
android:layout_height="46dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_repeat"
android:src="@drawable/ic_repeat"
android:tint="@color/widget_icon_tint" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,216 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:minHeight="120dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="8dp"
android:paddingBottom="12dp"
android:orientation="horizontal"
android:baselineAligned="false"
android:background="@drawable/widget_bg">
<FrameLayout
android:id="@+id/album_art_container"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_gravity="center_vertical">
<ImageView
android:id="@+id/album_art"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:contentDescription="@string/widget_content_desc_album_art" />
</FrameLayout>
<LinearLayout
android:id="@+id/content"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical"
android:weightSum="1">
<LinearLayout
android:id="@+id/text_container"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:textStyle="bold"
android:textSize="16sp"
android:textColor="@color/widget_title"
android:includeFontPadding="false"
android:freezesText="true" />
<LinearLayout
android:id="@+id/subtitle_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="1dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:baselineAligned="false">
<TextView
android:id="@+id/subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:singleLine="true"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:textSize="13sp"
android:textColor="@color/widget_subtitle"
android:includeFontPadding="false"
android:freezesText="true" />
<TextView
android:id="@+id/album"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:ellipsize="marquee"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:gravity="end"
android:textAlignment="viewEnd"
android:textSize="12sp"
android:textColor="@color/widget_subtitle"
android:includeFontPadding="false"
android:freezesText="true"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
<ProgressBar
android:id="@+id/progress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="3dp"
android:layout_marginTop="4dp"
android:indeterminate="false"
android:max="1000"
android:progress="0"
android:progressBackgroundTint="@color/widget_subtitle"
android:progressTint="@color/widget_icon_tint" />
<LinearLayout
android:id="@+id/timing"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:orientation="horizontal">
<TextView
android:id="@+id/time_elapsed"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/widget_time_elapsed_placeholder"
android:textColor="@color/widget_subtitle"
android:textSize="10sp" />
<TextView
android:id="@+id/time_total"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:text="@string/widget_time_duration_placeholder"
android:textColor="@color/widget_subtitle"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/controls_secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center"
android:orientation="horizontal">
<ImageButton
android:id="@+id/btn_shuffle"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_marginEnd="1dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_shuffle"
android:src="@drawable/ic_shuffle"
android:tint="@color/widget_icon_tint" />
<LinearLayout
android:id="@+id/controls"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="3"
android:gravity="center"
android:orientation="horizontal">
<ImageButton
android:id="@+id/btn_prev"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_marginStart="1dp"
android:layout_marginEnd="1dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_prev"
android:src="@drawable/ic_skip_previous"
android:tint="@color/widget_icon_tint" />
<ImageButton
android:id="@+id/btn_play_pause"
android:layout_width="0dp"
android:layout_height="34dp"
android:layout_marginStart="1dp"
android:layout_marginEnd="1dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_play_pause"
android:src="@drawable/ic_play"
android:tint="@color/widget_icon_tint" />
<ImageButton
android:id="@+id/btn_next"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_marginStart="1dp"
android:layout_marginEnd="1dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_next"
android:src="@drawable/ic_skip_next"
android:tint="@color/widget_icon_tint" />
</LinearLayout>
<ImageButton
android:id="@+id/btn_repeat"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_marginStart="1dp"
android:layout_weight="1"
android:background="@android:color/transparent"
android:contentDescription="@string/widget_content_desc_repeat"
android:src="@drawable/ic_repeat"
android:tint="@color/widget_icon_tint" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="64dp"
android:paddingLeft="8dp"
android:paddingTop="0dp"
android:paddingRight="0dp"
android:paddingBottom="8dp"
android:background="@drawable/widget_bg">
<ImageView
android:id="@+id/album_art"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_centerVertical="true"
android:scaleType="centerCrop"
android:src="@drawable/ic_splash_logo"
android:contentDescription="@string/widget_content_desc_album_art"/>
<LinearLayout
android:id="@+id/texts"
android:orientation="vertical"
android:layout_toEndOf="@id/album_art"
android:layout_toStartOf="@id/controls"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="8dp">
<TextView
android:id="@+id/title"
android:maxLines="1"
android:ellipsize="end"
android:textStyle="bold"
android:textSize="14sp"
android:textColor="@color/widget_title"
android:text="@string/widget_not_playing"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/subtitle"
android:maxLines="1"
android:ellipsize="end"
android:textSize="12sp"
android:textColor="@color/widget_subtitle"
android:text="@string/widget_placeholder_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
<LinearLayout
android:id="@+id/controls"
android:layout_alignParentEnd="true"
android:orientation="horizontal"
android:gravity="center"
android:layout_width="wrap_content"
android:layout_height="match_parent">
<ImageButton android:id="@+id/btn_prev"
android:layout_width="48dp" android:layout_height="48dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_skip_previous"
android:tint="@color/widget_icon_tint"
android:contentDescription="@string/widget_content_desc_prev"/>
<ImageButton android:id="@+id/btn_play_pause"
android:layout_width="48dp" android:layout_height="48dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_play"
android:tint="@color/widget_icon_tint"
android:contentDescription="@string/widget_content_desc_play_pause"/>
<ImageButton android:id="@+id/btn_next"
android:layout_width="48dp" android:layout_height="48dp"
android:background="@android:color/transparent"
android:src="@drawable/ic_skip_next"
android:tint="@color/widget_icon_tint"
android:contentDescription="@string/widget_content_desc_next"/>
</LinearLayout>
</RelativeLayout>

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