211 Commits

Author SHA1 Message Date
eddyizm
a06ab77b42 chore: bumped version for release 2025-10-16 14:26:14 -07:00
eddyizm
04a6176bfd fix: limits image size to prevent widget crash #172 (#175) 2025-10-16 13:25:25 -07:00
eddyizm
1f4464e089 fix: Include shuffle/repeat controls in f-droid build's media notific… (#174) 2025-10-16 13:25:04 -07:00
eddyizm
9d01d2057a fix: limits image size to prevent widget crash #172 2025-10-15 21:57:24 -07:00
eddyizm
ad440c490a Fix album parse empty date field (#171) 2025-10-15 07:26:22 -07:00
le-firehawk
acdcfff9ac fix: Include shuffle/repeat controls in f-droid build's media notification window 2025-10-15 21:00:39 +10:30
eddyizm
8c7a25cbd0 fix: update to handle nulls in the sort function 2025-10-13 21:49:41 -07:00
eddyizm
bdca5e16ed Merge branch 'development' into fix-album-parse-empty-date-field 2025-10-13 21:10:01 -07:00
eddyizm
f091b3d248 fix: handle empty date fields from subsonic json 2025-10-13 21:09:27 -07:00
eddyizm
18cd84f820 fix: persist album sort preference (#168) 2025-10-13 16:53:06 -07:00
eddyizm
281ebf8263 fix: General build warning and playback issues (#167) 2025-10-13 06:59:33 -07:00
eddyizm
2854ac6354 fix: persist album sort preference. 2025-10-12 22:12:53 -07:00
eddyizm
16d25a1f1d Merge branch 'main' into development 2025-10-12 09:57:55 -07:00
eddyizm
5d3ca8acfa chore: added multi library documentation 2025-10-12 09:57:35 -07:00
eddyizm
0689272046 fix: workflow trigger updated for my tagging convention 2025-10-12 09:56:52 -07:00
le-firehawk
17372fc4d0 fix: Resolve parcel serialization build warnings 2025-10-12 23:20:47 +10:30
le-firehawk
44679855cd fix: Replace poor syntax that created warnings during build 2025-10-12 23:20:47 +10:30
le-firehawk
78e7032903 fix: When creating MediaService, restore player from previous queue 2025-10-12 23:20:47 +10:30
eddyizm
8d8087f2d6 chore: fix some grammar in readme 2025-10-11 09:07:38 -07:00
eddyizm
5b6a4fab62 chore: updated changelog 2025-10-10 22:30:55 -07:00
eddyizm
fdc41b299c chore: updated ignore file for release apk files 2025-10-10 22:29:41 -07:00
eddyizm
82c22ed247 chore: bumped version for release 2025-10-10 22:17:09 -07:00
eddyizm
48ce3a2a4f chore: updated release info 2025-10-10 22:02:01 -07:00
eddyizm
b93acc6563 fix: Glide module incorrectly encoding IPv6 addresses (#159) 2025-10-09 22:18:49 -07:00
eddyizm
9c088a7e88 feat: Make all objects in Tempo references for quick access (#158) 2025-10-09 22:18:11 -07:00
eddyizm
de2f1067a7 chore: Update Polish translation (#160) 2025-10-09 22:11:19 -07:00
eddyizm
1c21546461 chore: adding screenshot and docs for 4 icons/buttons in player control (#162) 2025-10-09 21:54:38 -07:00
eddyizm
a4121e8d49 chore: adding screenshot and docs for 4 icons/buttons in player control 2025-10-09 21:53:52 -07:00
skajmer
4cc4cc7363 Update Polish translation
Stuff from:
#140 
#135 
#98 
#152
2025-10-09 21:41:12 +02:00
le-firehawk
c5ef274916 fix: Glide module incorrectly encoding IPv6 addresses 2025-10-09 23:14:54 +10:30
le-firehawk
2c53f36a18 fix: Support content URIs for external downloader 2025-10-09 23:03:57 +10:30
le-firehawk
6c637dcbcb feat: Make all objects in Tempo references for quick access 2025-10-09 23:03:57 +10:30
eddyizm
89fa38f5a0 chore: updated change log 2025-10-08 21:55:00 -07:00
eddyizm
87f8bdc618 chore: bump version and remove play details from build 2025-10-08 21:50:10 -07:00
eddyizm
903fde4bdc fix: Replace hardcoded strings in SettingsFragment (#152) 2025-10-08 21:38:49 -07:00
eddyizm
3824dd882c Merge branch 'development' into fix-hardcoded-strings 2025-10-08 21:38:31 -07:00
eddyizm
602bab6414 feat: Show sampling rate and bit depth in downloads (#154) 2025-10-08 21:30:54 -07:00
eddyizm
d891e429b6 fix: updating release workflow to account for the 32/64 bit builds an… (#156) 2025-10-08 21:30:11 -07:00
eddyizm
ebefd77027 chore: removed play variant (#155) 2025-10-08 21:29:59 -07:00
eddyizm
57f34affd9 fix: Re-add new equalizer settings that got lost (#153) 2025-10-08 21:29:40 -07:00
eddyizm
50b5ab38bc chore(i18n): Update Spanish translation (#151) 2025-10-08 21:27:15 -07:00
eddyizm
9f61d70fca fix: updating release workflow to account for the 32/64 bit builds and degoogled variant 2025-10-08 21:26:06 -07:00
eddyizm
a97a2d5b50 chore: removed play variant 2025-10-08 21:10:45 -07:00
Jaime García
8d73a2cd36 fix: Re-add new equalizer settings that got lost 2025-10-09 04:52:40 +02:00
Jaime García
ca5a0698bb feat: Show sampling rate and bit depth in downloads 2025-10-09 04:32:22 +02:00
Jaime García
04f34e03d1 fix: Replace hardcoded strings in SettingsFragment 2025-10-08 22:17:30 +02:00
Jaime García
233bc9987e chore(i18n): Update Spanish translation 2025-10-08 21:05:55 +02:00
Jaime García
f0e418687e chore(i18n): Update Spanish translation 2025-10-08 20:41:47 +02:00
eddyizm
e87b658447 chore: random blank file i probably added 2025-10-07 22:20:55 -07:00
eddyizm
19c985c9e4 chore: updated changelog 2025-10-07 22:20:19 -07:00
eddyizm
4ab122a9d7 Merge branch 'development' 2025-10-07 21:52:10 -07:00
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
2335bf2095 fix: removed universal ref causing build issues 2025-09-28 18:59:21 -07:00
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
172 changed files with 11301 additions and 1723 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']

View File

@@ -3,7 +3,7 @@ name: Github Release Workflow
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
build:
@@ -35,12 +35,18 @@ jobs:
echo "BUILD_TOOL_VERSION=$BUILD_TOOL_VERSION" >> $GITHUB_ENV
echo Last build tool version is: $BUILD_TOOL_VERSION
- name: Build APK
- name: Build All APKs
id: build
run: bash ./gradlew assembleTempoRelease
run: |
# Build release variants
bash ./gradlew assembleTempoRelease
bash ./gradlew assembleNotquitemyRelease
# Build debug variants
bash ./gradlew assembleTempoDebug
bash ./gradlew assembleNotquitemyDebug
- name: Sign APK
id: sign_apk
- name: Sign Tempo Release APKs
id: sign_tempo_release
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/tempo/release
@@ -51,11 +57,17 @@ jobs:
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
- name: Make artifact
uses: actions/upload-artifact@v4
- name: Sign NotQuiteMy Release APKs
id: sign_notquitemy_release
uses: r0adkll/sign-android-release@v1
with:
name: app-release-signed
path: ${{steps.sign_apk.outputs.signedReleaseFile}}
releaseDirectory: app/build/outputs/apk/notquitemy/release
signingKeyBase64: ${{ secrets.KEYSTORE_BASE64 }}
alias: ${{ secrets.KEY_ALIAS_GITHUB }}
keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD_GITHUB }}
env:
BUILD_TOOLS_VERSION: ${{ env.BUILD_TOOL_VERSION }}
- name: Create Release
id: create_release
@@ -67,12 +79,40 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Upload APK
- name: Upload Release APKs
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ${{steps.sign_apk.outputs.signedReleaseFile}}
asset_path: ${{steps.sign_tempo_release.outputs.signedReleaseFile}}
asset_name: app-tempo-release.apk
asset_content_type: application/zip
asset_content_type: application/vnd.android.package-archive
- name: Upload NotQuiteMy Release APK
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ${{steps.sign_notquitemy_release.outputs.signedReleaseFile}}
asset_name: app-notquitemy-release.apk
asset_content_type: application/vnd.android.package-archive
- name: Upload Debug APKs as artifacts
uses: actions/upload-artifact@v4
with:
name: debug-apks
path: |
app/build/outputs/apk/tempo/debug/
app/build/outputs/apk/notquitemy/debug/
retention-days: 30
- name: Upload Release APKs as artifacts
uses: actions/upload-artifact@v4
with:
name: release-apks
path: |
${{steps.sign_tempo_release.outputs.signedReleaseFile}}
${{steps.sign_notquitemy_release.outputs.signedReleaseFile}}
retention-days: 30

3
.gitignore vendored
View File

@@ -17,4 +17,5 @@
.vscode/settings.json
# release / debug files
tempus-release-key.jks
app/tempo/
app/tempo/
app/notquitemy/

View File

@@ -2,6 +2,127 @@
***This log is for this fork to detail updates since 3.9.0 from the main repo.***
## [3.17.0](https://github.com/eddyizm/tempo/releases/tag/v3.17.0) (2025-10-10)
## What's Changed
* chore: adding screenshot and docs for 4 icons/buttons in player control by @eddyizm in https://github.com/eddyizm/tempo/pull/162
* Update Polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/160
* feat: Make all objects in Tempo references for quick access by @le-firehawk in https://github.com/eddyizm/tempo/pull/158
* fix: Glide module incorrectly encoding IPv6 addresses by @le-firehawk in https://github.com/eddyizm/tempo/pull/159
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.16.6...v3.17.0
## [3.16.6](https://github.com/eddyizm/tempo/releases/tag/v3.16.6) (2025-10-08)
## What's Changed
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempo/pull/151
* fix: Re-add new equalizer settings that got lost by @jaime-grj in https://github.com/eddyizm/tempo/pull/153
* chore: removed play variant by @eddyizm in https://github.com/eddyizm/tempo/pull/155
* fix: updating release workflow to account for the 32/64 bit builds an… by @eddyizm in https://github.com/eddyizm/tempo/pull/156
* feat: Show sampling rate and bit depth in downloads by @jaime-grj in https://github.com/eddyizm/tempo/pull/154
* fix: Replace hardcoded strings in SettingsFragment by @jaime-grj in https://github.com/eddyizm/tempo/pull/152
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.16.0...v3.16.6
## [3.16.0](https://github.com/eddyizm/tempo/releases/tag/v3.16.0) (2025-10-07)
## What's Changed
* chore: add sha256 fingerprint for validation by @eddyizm in https://github.com/eddyizm/tempo/commit/3c58e6fbb2157a804853259dfadbbffe3b6793b5
* fix: Prevent crash when getting artist radio and song list is null by @jaime-grj in https://github.com/eddyizm/tempo/pull/117
* chore: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/125
* fix: Update search query validation to require at least 2 characters instead of 3 by @jaime-grj in https://github.com/eddyizm/tempo/pull/124
* feat: download starred artists. by @eddyizm in https://github.com/eddyizm/tempo/pull/137
* feat: Enable downloading of song lyrics for offline viewing by @le-firehawk in https://github.com/eddyizm/tempo/pull/99
* fix: Lag during startup when local url is not available by @SinTan1729 in https://github.com/eddyizm/tempo/pull/110
* chore: add link to discussion page in settings by @eddyizm in https://github.com/eddyizm/tempo/pull/143
* feat: Notification heart rating by @eddyizm in https://github.com/eddyizm/tempo/pull/140
* chore: Unify and update polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/146
* chore: added sha256 signing key for verification by @eddyizm in https://github.com/eddyizm/tempo/pull/147
* feat: Support user-defined download directory for media by @le-firehawk in https://github.com/eddyizm/tempo/pull/21
* feat: Added support for skipping duplicates by @SinTan1729 in https://github.com/eddyizm/tempo/pull/135
* feat: Add home screen music playback widget and some updates in Turkish localization by @mucahit-kaya in https://github.com/eddyizm/tempo/pull/98
## New Contributors
* @SinTan1729 made their first contribution in https://github.com/eddyizm/tempo/pull/110
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.15.0...v3.16.0
## [3.15.0](https://github.com/eddyizm/tempo/releases/tag/v3.15.0) (2025-09-23)
## What's Changed
* chore: Update French localization by @benoit-smith in https://github.com/eddyizm/tempo/pull/84
* chore: Update RU locale by @ArchiDevil in https://github.com/eddyizm/tempo/pull/87
* chore: Update Korean translations by @kongwoojin in https://github.com/eddyizm/tempo/pull/97
* fix: only plays the first song on an album by @eddyizm in https://github.com/eddyizm/tempo/pull/81
* fix: handle null and not crash when disconnecting chromecast by @eddyizm in https://github.com/eddyizm/tempo/pull/81
* feat: Built-in audio equalizer by @jaime-grj in https://github.com/eddyizm/tempo/pull/94
* fix: Resolve playback issues with live radio MPEG & HLS streams by @jaime-grj in https://github.com/eddyizm/tempo/pull/89
* chore: Updates to polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/105
* feat: added 32bit build and debug build for testing. Removed unused f… by @eddyizm in https://github.com/eddyizm/tempo/pull/108
* feat: Mark currently playing song with play/pause button by @jaime-grj in https://github.com/eddyizm/tempo/pull/107
* fix: add listener to track playlist click/change by @eddyizm in https://github.com/eddyizm/tempo/pull/113
* feat: Tap anywhere on the song item to toggle playback by @jaime-grj in https://github.com/eddyizm/tempo/pull/112
## New Contributors
* @ArchiDevil made their first contribution in https://github.com/eddyizm/tempo/pull/87
* @kongwoojin made their first contribution in https://github.com/eddyizm/tempo/pull/97
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.14.8...v3.15.0
## [3.14.8](https://github.com/eddyizm/tempo/releases/tag/v3.14.8) (2025-08-30)
## What's Changed
* fix: Use correct SearchView widget to avoid crash in AlbumListPageFragment by @jaime-grj in https://github.com/eddyizm/tempo/pull/76
* chore(i18n): Update Spanish (es-ES) and English translations by @jaime-grj in https://github.com/eddyizm/tempo/pull/77
* style: Center subtitle text in empty_download_layout in fragment_download.xml when there is more than one line by @jaime-grj in https://github.com/eddyizm/tempo/pull/78
* fix: Disable "sync starred tracks/albums" switches when Cancel is clicked in warning dialog, use proper view for "Sync starred albums" dialog by @jaime-grj in https://github.com/eddyizm/tempo/pull/79
* bug fixes, chores, docs v3.14.8 by @eddyizm in https://github.com/eddyizm/tempo/pull/80
**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.14.1...v3.14.8
## [3.14.1](https://github.com/eddyizm/tempo/releases/tag/v3.14.1) (2025-08-30)
## What's Changed
* feat: rating dialog added to album page by @eddyizm in https://github.com/eddyizm/tempo/pull/52
* 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,12 +24,29 @@ Tempo does not rely on magic algorithms to decide what you should listen to. Ins
## Fork
sha256 signing key fingerprint
`B7:85:01:B9:34:D0:4E:0A:CA:8D:94:AF:D6:72:6A:4D:1D:CE:65:79:7F:1D:41:71:0F:64:3C:29:00:EB:1D:1D`
This fork is my attempt to keep development moving forward and merge in PR's that have been sitting for a while in the main repo. Thankful to @CappielloAntonio for the amazing app and hopefully we can continue to build on top of it. I will only be releasing on github and if I am not able to merge back to the main repo, I plan to rename the app to be able to publish it to fdroid and possibly google play? We will see.
Moved details to [CHANGELOG.md](https://github.com/eddyizm/tempo/blob/main/CHANGELOG.md)
### Releases
Please note the two variants in the release assets include release/debug and 32/64 bit flavors.
`app-tempo` <- The github release with all the android auto/chromecast features
`app-notquitemy*` <- The f-droid release that goes without any of the google stuff. It was last released at 3.8.1 from the original repo. Since I don't have access to that original repo, I am releasing the apk's here on github.
As mentioned above, I am working towards a rebrand to get into app stores with a new name an icon.
Moved details to [CHANGELOG.md](CHANGELOG.md)
Fork [**sponsorship here**](https://ko-fi.com/eddyizm).
## 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.
@@ -42,13 +59,11 @@ Fork [**sponsorship here**](https://ko-fi.com/eddyizm).
- **Podcasts and Radio**: If your Subsonic server supports it, listen to podcasts and radio shows directly within Tempo, expanding your audio entertainment options.
- **Transcoding Support**: Activate transcoding of tracks on your Subsonic server, allowing you to set a transcoding profile for optimized streaming directly from the app. This feature requires support from your Subsonic server.
- **Android Auto Support**: Enjoy your favorite music on the go with full Android Auto integration, allowing you to seamlessly control and listen to your tracks directly from your mobile device while driving.
- **Multiple Libraries**: Tempo handles multi-library setups gracefully. They are displayed as Library folders.
## Sponsors
## Credits
Thanks to the original repo/creator [CappielloAntonio](https://github.com/CappielloAntonio) (3.9.0)
Tempo is an open-source project developed and maintained solely by me. I would like to express my heartfelt thanks to all the users who have shown their love and support for Tempo. Your contributions and encouragement mean a lot to me, and they help drive the development and improvement of the app.
## Screenshot
<p align="center">
@@ -83,6 +98,16 @@ Tempo is an open-source project developed and maintained solely by me. I would l
<img src="mockup/dark/8_screenshot.png" width=200>
</p>
## Contributing
Please fork and open PR's against the development branch. Make sure your PR builds successfully.
If there is an UI change, please include a before/after screenshot and a short video/gif if that helps elaborating the fix/feature in the PR.
Currently there are no tests but I would love to start on some unit tests.
Not a hard requirement but any new feature/change should ideally include an update to the nacent documention.
## License
Tempo is released under the [GNU General Public License v3.0](LICENSE). Feel free to modify, distribute, and use the app in accordance with the terms of the license. Contributions to the project are also welcome.

166
USAGE.md Normal file
View File

@@ -0,0 +1,166 @@
# 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
**Multi-library**
Tempo handles multi-library setups gracefully. They are displayed as Library folders.
However, if you want to limit or change libraries you could use a workaround, if your server supports it.
You can create multiple users , one for each library, and save each of them in Tempo app.
### Now Playing Screen
On the main player control screen, tapping on the artwork will reveal a small collection of 4 buttons/icons.
<p align="left">
<img src="mockup/usage/player_icons.png" width=159>
</p>
*marked the icons with numbers for clarity*
1. Downloads the track (there is a notification if the android screen but not a pop toast currently )
2. Adds track to playlist - pops up playlist dialog.
3. Adds tracks to the queue via instant mix function
4. Saves play queue (if the feature is enabled in the settings)
* if the setting is not enabled, it toggles a view of the lyrics if available (slides to the right)
## Navigation
### 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 28
versionName '3.12.0'
versionCode 36
versionName '3.17.14'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
javaCompileOptions {
@@ -23,8 +22,21 @@ android {
]
}
}
}
splits {
abi {
enable true
reset()
//noinspection ChromeOsAbiSupport
include 'armeabi-v7a', 'arm64-v8a'
universalApk false
}
}
flavorDimensions += "default"
productFlavors {
@@ -38,10 +50,6 @@ android {
applicationId "com.cappielloantonio.notquitemy.tempo"
}
play {
dimension = "default"
applicationId "com.cappielloantonio.play.tempo"
}
}
buildTypes {
@@ -51,6 +59,11 @@ android {
debuggable false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
applicationIdSuffix ".debug"
debuggable true
}
}
compileOptions {
@@ -98,7 +111,7 @@ dependencies {
implementation 'androidx.media3:media3-ui:1.5.1'
implementation 'androidx.media3:media3-exoplayer-hls:1.5.1'
tempoImplementation 'androidx.media3:media3-cast:1.5.1'
playImplementation 'androidx.media3:media3-cast:1.5.1'
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
annotationProcessor 'androidx.room:room-compiler:2.6.1'
@@ -112,4 +125,4 @@ java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,16 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="asset"
android:scheme="tempo" />
</intent-filter>
</activity>
<service
@@ -73,5 +83,20 @@
android:name="autoStoreLocales"
android:value="true" />
</service>
<receiver
android:name=".widget.WidgetProvider4x1"
android:exported="false"
android:label="@string/widget_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info"/>
</receiver>
</application>
</manifest>
</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

@@ -4,14 +4,18 @@ import android.content.Context;
import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import com.bumptech.glide.GlideBuilder;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory;
import com.bumptech.glide.Registry;
import com.bumptech.glide.module.AppGlideModule;
import com.bumptech.glide.request.RequestOptions;
import com.cappielloantonio.tempo.util.Preferences;
import java.io.InputStream;
@GlideModule
public class CustomGlideModule extends AppGlideModule {
@Override
@@ -20,4 +24,9 @@ public class CustomGlideModule extends AppGlideModule {
builder.setDiskCache(new InternalCacheDiskCacheFactory(context, "cache", diskCacheSize));
builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
}
@Override
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
registry.replace(String.class, InputStream.class, new IPv6StringLoader.Factory());
}
}

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,9 +111,21 @@ public class CustomGlideRequest {
return uri.toString();
}
public static void loadAlbumArtBitmap(Context context,
String coverId,
int size,
CustomTarget<Bitmap> target) {
String url = createUrl(coverId, size);
Glide.with(context)
.asBitmap()
.load(url)
.apply(createRequestOptions(context, coverId, ResourceType.Album))
.into(target);
}
public static class Builder {
private final RequestManager requestManager;
private Object item;
private String item;
private Builder(Context context, String item, ResourceType type) {
this.requestManager = Glide.with(context);

View File

@@ -0,0 +1,110 @@
package com.cappielloantonio.tempo.glide;
import androidx.annotation.NonNull;
import com.bumptech.glide.Priority;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.Options;
import com.bumptech.glide.load.data.DataFetcher;
import com.bumptech.glide.load.model.ModelLoader;
import com.bumptech.glide.load.model.ModelLoaderFactory;
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
import com.bumptech.glide.signature.ObjectKey;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
public class IPv6StringLoader implements ModelLoader<String, InputStream> {
private static final int DEFAULT_TIMEOUT_MS = 2500;
@Override
public boolean handles(@NonNull String model) {
return model.startsWith("http://") || model.startsWith("https://");
}
@Override
public LoadData<InputStream> buildLoadData(@NonNull String model, int width, int height, @NonNull Options options) {
if (!handles(model)) {
return null;
}
return new LoadData<>(new ObjectKey(model), new IPv6StreamFetcher(model));
}
private static class IPv6StreamFetcher implements DataFetcher<InputStream> {
private final String model;
private InputStream stream;
private HttpURLConnection connection;
IPv6StreamFetcher(String model) {
this.model = model;
}
@Override
public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) {
try {
URL url = new URL(model);
connection = (HttpURLConnection) url.openConnection();
connection.setConnectTimeout(DEFAULT_TIMEOUT_MS);
connection.setReadTimeout(DEFAULT_TIMEOUT_MS);
connection.setUseCaches(true);
connection.setDoInput(true);
connection.connect();
if (connection.getResponseCode() / 100 != 2) {
callback.onLoadFailed(new IOException("Request failed with status code: " + connection.getResponseCode()));
return;
}
stream = connection.getInputStream();
callback.onDataReady(stream);
} catch (IOException e) {
callback.onLoadFailed(e);
}
}
@Override
public void cleanup() {
if (stream != null) {
try {
stream.close();
} catch (IOException ignored) {
}
}
if (connection != null) {
connection.disconnect();
}
}
@Override
public void cancel() {
// HttpURLConnection does not provide a direct cancel mechanism.
}
@NonNull
@Override
public Class<InputStream> getDataClass() {
return InputStream.class;
}
@NonNull
@Override
public DataSource getDataSource() {
return DataSource.REMOTE;
}
}
public static class Factory implements ModelLoaderFactory<String, InputStream> {
@NonNull
@Override
public ModelLoader<String, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) {
return new IPv6StringLoader();
}
@Override
public void teardown() {
// No-op
}
}
}

View File

@@ -8,18 +8,18 @@ import androidx.room.PrimaryKey
import com.cappielloantonio.tempo.subsonic.models.Child
import com.cappielloantonio.tempo.util.Preferences
import kotlinx.parcelize.Parcelize
import java.util.*
import java.util.Date
@Keep
@Parcelize
@Entity(tableName = "chronology")
class Chronology(@PrimaryKey override val id: String) : Child(id) {
class Chronology(
@PrimaryKey override val id: String,
@ColumnInfo(name = "timestamp")
var timestamp: Long = System.currentTimeMillis()
var timestamp: Long = System.currentTimeMillis(),
@ColumnInfo(name = "server")
var server: String? = null
var server: String? = null,
) : Child(id) {
constructor(mediaItem: MediaItem) : this(mediaItem.mediaMetadata.extras!!.getString("id")!!) {
parentId = mediaItem.mediaMetadata.extras!!.getString("parentId")
isDir = mediaItem.mediaMetadata.extras!!.getBoolean("isDir")

View File

@@ -10,19 +10,17 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
@Entity(tableName = "download")
class Download(@PrimaryKey override val id: String) : Child(id) {
class Download(
@PrimaryKey override val id: String,
@ColumnInfo(name = "playlist_id")
var playlistId: String? = null
var playlistId: String? = null,
@ColumnInfo(name = "playlist_name")
var playlistName: String? = null
var playlistName: String? = null,
@ColumnInfo(name = "download_state", defaultValue = "1")
var downloadState: Int = 0
var downloadState: Int = 0,
@ColumnInfo(name = "download_uri", defaultValue = "")
var downloadUri: String? = null
var downloadUri: String? = null,
) : Child(id) {
constructor(child: Child) : this(child.id) {
parentId = child.parentId
isDir = child.isDir
@@ -40,6 +38,8 @@ class Download(@PrimaryKey override val id: String) : Child(id) {
transcodedSuffix = child.transcodedSuffix
duration = child.duration
bitrate = child.bitrate
samplingRate = child.samplingRate
bitDepth = child.bitDepth
path = child.path
isVideo = child.isVideo
userRating = child.userRating
@@ -60,5 +60,5 @@ class Download(@PrimaryKey override val id: String) : Child(id) {
@Keep
data class DownloadStack(
var id: String,
var view: String?
var view: String?,
)

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

@@ -10,20 +10,18 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
@Entity(tableName = "queue")
class Queue(override val id: String) : Child(id) {
class Queue(
override val id: String,
@PrimaryKey
@ColumnInfo(name = "track_order")
var trackOrder: Int = 0
var trackOrder: Int = 0,
@ColumnInfo(name = "last_play")
var lastPlay: Long = 0
var lastPlay: Long = 0,
@ColumnInfo(name = "playing_changed")
var playingChanged: Long = 0
var playingChanged: Long = 0,
@ColumnInfo(name = "stream_id")
var streamId: String? = null
var streamId: String? = null,
) : Child(id) {
constructor(child: Child) : this(child.id) {
parentId = child.parentId
isDir = child.isDir

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,6 +2,7 @@ package com.cappielloantonio.tempo.repository;
import androidx.annotation.NonNull;
import androidx.lifecycle.MutableLiveData;
import android.util.Log;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.interfaces.DecadesCallback;
@@ -31,14 +32,22 @@ public class AlbumRepository {
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getAlbumList2() != null && response.body().getSubsonicResponse().getAlbumList2().getAlbums() != null) {
if (response.isSuccessful()
&& response.body() != null
&& response.body().getSubsonicResponse().getAlbumList2() != null
&& response.body().getSubsonicResponse().getAlbumList2().getAlbums() != null) {
listLiveAlbums.setValue(response.body().getSubsonicResponse().getAlbumList2().getAlbums());
} else {
Log.e("AlbumRepository", "API Error on getAlbums. HTTP Code: " + response.code());
listLiveAlbums.setValue(new ArrayList<>());
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.e("AlbumRepository", "Network Failure on getAlbums: " + t.getMessage());
listLiveAlbums.setValue(new ArrayList<>());
}
});

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

@@ -80,21 +80,52 @@ public class PlaylistRepository {
return listLivePlaylistSongs;
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) {
public MutableLiveData<Playlist> getPlaylist(String id) {
MutableLiveData<Playlist> playlistLiveData = new MutableLiveData<>();
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.updatePlaylist(playlistId, null, true, songsId, null)
.getPlaylist(id)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show();
if (response.isSuccessful()
&& response.body() != null
&& response.body().getSubsonicResponse().getPlaylist() != null) {
playlistLiveData.setValue(response.body().getSubsonicResponse().getPlaylist());
} else {
playlistLiveData.setValue(null);
}
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show();
playlistLiveData.setValue(null);
}
});
return playlistLiveData;
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) {
if (songsId.isEmpty()) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show();
} else{
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.updatePlaylist(playlistId, null, true, songsId, null)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show();
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show();
}
});
}
}
public void createPlaylist(String playlistId, String name, ArrayList<String> songsId) {
@@ -131,23 +162,6 @@ public class PlaylistRepository {
});
}
public void updatePlaylist(String playlistId, String name, boolean isPublic, ArrayList<String> songIdToAdd, ArrayList<Integer> songIndexToRemove) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.updatePlaylist(playlistId, name, isPublic, songIdToAdd, songIndexToRemove)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {
}
@Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
}
});
}
public void deletePlaylist(String playlistId) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()

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

@@ -2,22 +2,28 @@ package com.cappielloantonio.tempo.subsonic
import com.cappielloantonio.tempo.App
import com.cappielloantonio.tempo.subsonic.utils.CacheUtil
import com.cappielloantonio.tempo.subsonic.utils.EmptyDateTypeAdapter
import com.google.gson.GsonBuilder
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.Date
import java.util.concurrent.TimeUnit
class RetrofitClient(subsonic: Subsonic) {
var retrofit: Retrofit
init {
val gson = GsonBuilder()
.registerTypeAdapter(Date::class.java, EmptyDateTypeAdapter())
.setLenient()
.create()
retrofit = Retrofit.Builder()
.baseUrl(subsonic.url)
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create()))
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().setLenient().create()))
.addConverterFactory(GsonConverterFactory.create(gson))
.client(getOkHttpClient())
.build()
}

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

@@ -4,38 +4,36 @@ import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
import java.time.Instant
import java.time.LocalDate
import java.util.*
import java.util.Date
@Keep
@Parcelize
open class AlbumID3 : Parcelable {
var id: String? = null
var name: String? = null
var artist: String? = null
var artistId: String? = null
open class AlbumID3(
var id: String? = null,
var name: String? = null,
var artist: String? = null,
var artistId: String? = null,
@SerializedName("coverArt")
var coverArtId: String? = null
var songCount: Int? = 0
var duration: Int? = 0
var playCount: Long? = 0
var created: Date? = null
var starred: Date? = null
var year: Int = 0
var genre: String? = null
var played: Date? = Date(0)
var userRating: Int? = 0
var recordLabels: List<RecordLabel>? = null
var musicBrainzId: String? = null
var genres: List<ItemGenre>? = null
var artists: List<ArtistID3>? = null
var displayArtist: String? = null
var releaseTypes: List<String>? = null
var moods: List<String>? = null
var sortName: String? = null
var originalReleaseDate: ItemDate? = null
var releaseDate: ItemDate? = null
var isCompilation: Boolean? = null
var discTitles: List<DiscTitle>? = null
}
var coverArtId: String? = null,
var songCount: Int? = 0,
var duration: Int? = 0,
var playCount: Long? = 0,
var created: Date? = null,
var starred: Date? = null,
var year: Int = 0,
var genre: String? = null,
var played: Date? = Date(0),
var userRating: Int? = 0,
var recordLabels: List<RecordLabel>? = null,
var musicBrainzId: String? = null,
var genres: List<ItemGenre>? = null,
var artists: List<ArtistID3>? = null,
var displayArtist: String? = null,
var releaseTypes: List<String>? = null,
var moods: List<String>? = null,
var sortName: String? = null,
var originalReleaseDate: ItemDate? = null,
var releaseDate: ItemDate? = null,
var isCompilation: Boolean? = null,
var discTitles: List<DiscTitle>? = null,
) : Parcelable

View File

@@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
class AlbumWithSongsID3 : AlbumID3(), Parcelable {
class AlbumWithSongsID3(
@SerializedName("song")
var songs: List<Child>? = null
}
var songs: List<Child>? = null,
) : AlbumID3(), Parcelable

View File

@@ -7,10 +7,10 @@ import java.util.Date
@Keep
@Parcelize
class Artist : Parcelable {
var id: String? = null
var name: String? = null
var starred: Date? = null
var userRating: Int? = null
var averageRating: Double? = null
}
class Artist(
var id: String? = null,
var name: String? = null,
var starred: Date? = null,
var userRating: Int? = null,
var averageRating: Double? = null,
) : Parcelable

View File

@@ -4,15 +4,15 @@ import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
import java.util.*
import java.util.Date
@Keep
@Parcelize
open class ArtistID3 : Parcelable {
var id: String? = null
var name: String? = null
open class ArtistID3(
var id: String? = null,
var name: String? = null,
@SerializedName("coverArt")
var coverArtId: String? = null
var albumCount = 0
var starred: Date? = null
}
var coverArtId: String? = null,
var albumCount: Int = 0,
var starred: Date? = null,
) : Parcelable

View File

@@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
class ArtistWithAlbumsID3 : ArtistID3(), Parcelable {
class ArtistWithAlbumsID3(
@SerializedName("album")
var albums: List<AlbumID3>? = null
}
var albums: List<AlbumID3>? = null,
) : ArtistID3(), Parcelable

View File

@@ -8,15 +8,15 @@ import java.util.Date
@Keep
@Parcelize
class Directory : Parcelable {
class Directory(
@SerializedName("child")
var children: List<Child>? = null
var id: String? = null
var children: List<Child>? = null,
var id: String? = null,
@SerializedName("parent")
var parentId: String? = null
var name: String? = null
var starred: Date? = null
var userRating: Int? = null
var averageRating: Double? = null
var playCount: Long? = null
}
var parentId: String? = null,
var name: String? = null,
var starred: Date? = null,
var userRating: Int? = null,
var averageRating: Double? = null,
var playCount: Long? = null,
) : Parcelable

View File

@@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
open class DiscTitle : Parcelable {
var disc: Int? = null
var title: String? = null
}
open class DiscTitle(
var disc: Int? = null,
var title: String? = null,
) : Parcelable

View File

@@ -7,9 +7,9 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
class Genre : Parcelable {
class Genre(
@SerializedName("value")
var genre: String? = null
var songCount = 0
var albumCount = 0
}
var genre: String? = null,
var songCount: Int = 0,
var albumCount: Int = 0,
) : Parcelable

View File

@@ -6,9 +6,9 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
class InternetRadioStation : Parcelable {
var id: String? = null
var name: String? = null
var streamUrl: String? = null
var homePageUrl: String? = null
}
class InternetRadioStation(
var id: String? = null,
var name: String? = null,
var streamUrl: String? = null,
var homePageUrl: String? = null,
) : Parcelable

View File

@@ -9,11 +9,11 @@ import java.util.Locale
@Keep
@Parcelize
open class ItemDate : Parcelable {
var year: Int? = null
var month: Int? = null
var day: Int? = null
open class ItemDate(
var year: Int? = null,
var month: Int? = null,
var day: Int? = null,
) : Parcelable {
fun getFormattedDate(): String? {
if (year == null && month == null && day == null) return null
@@ -22,8 +22,7 @@ open class ItemDate : Parcelable {
SimpleDateFormat("yyyy", Locale.getDefault())
} else if (day == null) {
SimpleDateFormat("MMMM yyyy", Locale.getDefault())
}
else{
} else {
SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault())
}

View File

@@ -6,6 +6,6 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
open class ItemGenre : Parcelable {
var name: String? = null
}
open class ItemGenre(
var name: String? = null,
) : Parcelable

View File

@@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
class MusicFolder : Parcelable {
var id: String? = null
var name: String? = null
}
class MusicFolder(
var id: String? = null,
var name: String? = null,
) : Parcelable

View File

@@ -8,10 +8,9 @@ import kotlinx.parcelize.Parcelize
@Parcelize
class NowPlayingEntry(
@SerializedName("_id")
override val id: String
) : Child(id) {
var username: String? = null
var minutesAgo = 0
var playerId = 0
var playerName: String? = null
}
override val id: String,
var username: String? = null,
var minutesAgo: Int = 0,
var playerId: Int = 0,
var playerName: String? = null,
) : Child(id)

View File

@@ -7,8 +7,9 @@ import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.util.*
import java.util.Date
@Keep
@Parcelize
@@ -16,27 +17,56 @@ import java.util.*
open class Playlist(
@PrimaryKey
@ColumnInfo(name = "id")
open var id: String
) : Parcelable {
open var id: String,
@ColumnInfo(name = "name")
var name: String? = null
var name: String? = null,
@ColumnInfo(name = "duration")
var duration: Long = 0,
@ColumnInfo(name = "coverArt")
var coverArtId: String? = null,
) : Parcelable {
@Ignore
@IgnoredOnParcel
var comment: String? = null
@Ignore
@IgnoredOnParcel
var owner: String? = null
@Ignore
@IgnoredOnParcel
@SerializedName("public")
var isUniversal: Boolean? = null
@Ignore
@IgnoredOnParcel
var songCount: Int = 0
@ColumnInfo(name = "duration")
var duration: Long = 0
@Ignore
@IgnoredOnParcel
var created: Date? = null
@Ignore
@IgnoredOnParcel
var changed: Date? = null
@ColumnInfo(name = "coverArt")
var coverArtId: String? = null
@Ignore
@IgnoredOnParcel
var allowedUsers: List<String>? = null
@Ignore
constructor(
id: String,
name: String?,
comment: String?,
owner: String?,
isUniversal: Boolean?,
songCount: Int,
duration: Long,
created: Date?,
changed: Date?,
coverArtId: String?,
allowedUsers: List<String>?,
) : this(id, name, duration, coverArtId) {
this.comment = comment
this.owner = owner
this.isUniversal = isUniversal
this.songCount = songCount
this.created = created
this.changed = changed
this.allowedUsers = allowedUsers
}
}

View File

@@ -9,8 +9,7 @@ import kotlinx.parcelize.Parcelize
@Parcelize
class PlaylistWithSongs(
@SerializedName("_id")
override var id: String
) : Playlist(id), Parcelable {
override var id: String,
@SerializedName("entry")
var entries: List<Child>? = null
}
var entries: List<Child>? = null,
) : Playlist(id), Parcelable

View File

@@ -6,5 +6,5 @@ import com.google.gson.annotations.SerializedName
@Keep
class Playlists(
@SerializedName("playlist")
var playlists: List<Playlist>? = null
var playlists: List<Playlist>? = null,
)

View File

@@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models
import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
class PodcastChannel : Parcelable {
class PodcastChannel(
@SerializedName("episode")
var episodes: List<PodcastEpisode>? = null
var id: String? = null
var url: String? = null
var title: String? = null
var description: String? = null
var episodes: List<PodcastEpisode>? = null,
var id: String? = null,
var url: String? = null,
var title: String? = null,
var description: String? = null,
@SerializedName("coverArt")
var coverArtId: String? = null
var originalImageUrl: String? = null
var status: String? = null
var errorMessage: String? = null
}
var coverArtId: String? = null,
var originalImageUrl: String? = null,
var status: String? = null,
var errorMessage: String? = null,
) : Parcelable

View File

@@ -3,37 +3,38 @@ package com.cappielloantonio.tempo.subsonic.models
import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.util.*
@Keep
@Parcelize
class PodcastEpisode : Parcelable {
var id: String? = null
class PodcastEpisode(
var id: String? = null,
@SerializedName("parent")
var parentId: String? = null
var isDir = false
var title: String? = null
var album: String? = null
var artist: String? = null
var year: Int? = null
var genre: String? = null
var parentId: String? = null,
var isDir: Boolean = false,
var title: String? = null,
var album: String? = null,
var artist: String? = null,
var year: Int? = null,
var genre: String? = null,
@SerializedName("coverArt")
var coverArtId: String? = null
var size: Long? = null
var contentType: String? = null
var suffix: String? = null
var duration: Int? = null
var coverArtId: String? = null,
var size: Long? = null,
var contentType: String? = null,
var suffix: String? = null,
var duration: Int? = null,
@SerializedName("bitRate")
var bitrate: Int? = null
var path: String? = null
var isVideo: Boolean = false
var created: Date? = null
var artistId: String? = null
var type: String? = null
var streamId: String? = null
var channelId: String? = null
var description: String? = null
var status: String? = null
var publishDate: Date? = null
}
var bitrate: Int? = null,
var path: String? = null,
var isVideo: Boolean = false,
var created: Date? = null,
var artistId: String? = null,
var type: String? = null,
var streamId: String? = null,
var channelId: String? = null,
var description: String? = null,
var status: String? = null,
var publishDate: Date? = null,
) : Parcelable

View File

@@ -2,10 +2,11 @@ package com.cappielloantonio.tempo.subsonic.models
import android.os.Parcelable
import androidx.annotation.Keep
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
@Keep
@Parcelize
open class RecordLabel : Parcelable {
var name: String? = null
}
open class RecordLabel(
var name: String? = null,
) : Parcelable

View File

@@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models
import android.os.Parcelable
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import java.util.*
import java.util.Date
@Keep
@Parcelize
class Share : Parcelable {
data class Share(
@SerializedName("entry")
var entries: List<Child>? = null
var id: String? = null
var url: String? = null
var description: String? = null
var username: String? = null
var created: Date? = null
var expires: Date? = null
var lastVisited: Date? = null
var visitCount = 0
}
var entries: List<Child>? = null,
var id: String? = null,
var url: String? = null,
var description: String? = null,
var username: String? = null,
var created: Date? = null,
var expires: Date? = null,
var lastVisited: Date? = null,
var visitCount: Int = 0
) : Parcelable

View File

@@ -0,0 +1,42 @@
package com.cappielloantonio.tempo.subsonic.utils
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import java.lang.reflect.Type
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
// This adapter handles Date objects, returning null if the JSON string is empty or unparsable.
class EmptyDateTypeAdapter : JsonDeserializer<Date> {
// Define the date formats expected from the Subsonic server.
private val dateFormats: List<SimpleDateFormat> = listOf(
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") },
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") },
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }
)
@Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Date? {
val jsonString = json.asString.trim()
if (jsonString.isEmpty()) {
return null
}
for (format in dateFormats) {
try {
return format.parse(jsonString)
} catch (e: ParseException) {
// Ignore and try the next format
}
}
return null
}
}

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;
@@ -31,6 +37,8 @@ import com.cappielloantonio.tempo.ui.dialog.ConnectionAlertDialog;
import com.cappielloantonio.tempo.ui.dialog.GithubTempoUpdateDialog;
import com.cappielloantonio.tempo.ui.dialog.ServerUnreachableDialog;
import com.cappielloantonio.tempo.ui.fragment.PlayerBottomSheetFragment;
import com.cappielloantonio.tempo.util.AssetLinkNavigator;
import com.cappielloantonio.tempo.util.AssetLinkUtil;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.MainViewModel;
@@ -54,8 +62,11 @@ public class MainActivity extends BaseActivity {
private BottomNavigationView bottomNavigationView;
public NavController navController;
private BottomSheetBehavior bottomSheetBehavior;
private AssetLinkNavigator assetLinkNavigator;
private AssetLinkUtil.AssetLink pendingAssetLink;
ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver;
private Intent pendingDownloadPlaybackIntent;
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -69,6 +80,7 @@ public class MainActivity extends BaseActivity {
setContentView(view);
mainViewModel = new ViewModelProvider(this).get(MainViewModel.class);
assetLinkNavigator = new AssetLinkNavigator(this);
connectivityStatusBroadcastReceiver = new ConnectivityStatusBroadcastReceiver(this);
connectivityStatusReceiverManager(true);
@@ -77,12 +89,16 @@ public class MainActivity extends BaseActivity {
checkConnectionType();
getOpenSubsonicExtensions();
checkTempoUpdate();
maybeSchedulePlaybackIntent(getIntent());
}
@Override
protected void onStart() {
super.onStart();
pingServer();
initService();
consumePendingPlaybackIntent();
}
@Override
@@ -98,6 +114,14 @@ public class MainActivity extends BaseActivity {
bind = null;
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
maybeSchedulePlaybackIntent(intent);
consumePendingPlaybackIntent();
}
@Override
public void onBackPressed() {
if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED)
@@ -292,6 +316,24 @@ public class MainActivity extends BaseActivity {
public void goFromLogin() {
setBottomSheetInPeek(mainViewModel.isQueueLoaded());
goToHome();
consumePendingAssetLink();
}
public void openAssetLink(@NonNull AssetLinkUtil.AssetLink assetLink) {
openAssetLink(assetLink, true);
}
public void openAssetLink(@NonNull AssetLinkUtil.AssetLink assetLink, boolean collapsePlayer) {
if (!isUserAuthenticated()) {
pendingAssetLink = assetLink;
return;
}
if (collapsePlayer) {
setBottomSheetInPeek(true);
}
if (assetLinkNavigator != null) {
assetLinkNavigator.open(assetLink);
}
}
public void quit() {
@@ -316,6 +358,7 @@ public class MainActivity extends BaseActivity {
Preferences.setSkipSilenceMode(false);
Preferences.setDataSavingMode(false);
Preferences.setStarredSyncEnabled(false);
Preferences.setStarredAlbumsSyncEnabled(false);
}
private void resetMusicSession() {
@@ -350,6 +393,7 @@ public class MainActivity extends BaseActivity {
Preferences.switchInUseServerAddress();
App.refreshSubsonicClient();
pingServer();
resetView();
} else {
Preferences.setOpenSubsonic(subsonicResponse.getOpenSubsonic() != null && subsonicResponse.getOpenSubsonic());
}
@@ -360,6 +404,7 @@ public class MainActivity extends BaseActivity {
Preferences.switchInUseServerAddress();
App.refreshSubsonicClient();
pingServer();
resetView();
} else {
mainViewModel.ping().observe(this, subsonicResponse -> {
if (subsonicResponse == null) {
@@ -375,6 +420,13 @@ public class MainActivity extends BaseActivity {
}
}
private void resetView() {
resetViewModel();
int id = Objects.requireNonNull(navController.getCurrentDestination()).getId();
navController.popBackStack(id, true);
navController.navigate(id);
}
private void getOpenSubsonicExtensions() {
if (Preferences.getToken() != null) {
mainViewModel.getOpenSubsonicExtensions().observe(this, openSubsonicExtensions -> {
@@ -407,4 +459,98 @@ public class MainActivity extends BaseActivity {
}
}
}
}
private void maybeSchedulePlaybackIntent(Intent intent) {
if (intent == null) return;
if (Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD.equals(intent.getAction())
|| intent.hasExtra(Constants.EXTRA_DOWNLOAD_URI)) {
pendingDownloadPlaybackIntent = new Intent(intent);
}
handleAssetLinkIntent(intent);
}
private void consumePendingPlaybackIntent() {
if (pendingDownloadPlaybackIntent == null) return;
Intent intent = pendingDownloadPlaybackIntent;
pendingDownloadPlaybackIntent = null;
playDownloadedMedia(intent);
}
private void handleAssetLinkIntent(Intent intent) {
AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.parse(intent);
if (assetLink == null) {
return;
}
if (!isUserAuthenticated()) {
pendingAssetLink = assetLink;
intent.setData(null);
return;
}
if (assetLinkNavigator != null) {
assetLinkNavigator.open(assetLink);
}
intent.setData(null);
}
private boolean isUserAuthenticated() {
return Preferences.getPassword() != null
|| (Preferences.getToken() != null && Preferences.getSalt() != null);
}
private void consumePendingAssetLink() {
if (pendingAssetLink == null || assetLinkNavigator == null) {
return;
}
assetLinkNavigator.open(pendingAssetLink);
pendingAssetLink = null;
}
private void playDownloadedMedia(Intent intent) {
String uriString = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_URI);
if (TextUtils.isEmpty(uriString)) {
return;
}
Uri uri = Uri.parse(uriString);
String mediaId = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID);
if (TextUtils.isEmpty(mediaId)) {
mediaId = uri.toString();
}
String title = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_TITLE);
String artist = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ARTIST);
String album = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ALBUM);
int duration = intent.getIntExtra(Constants.EXTRA_DOWNLOAD_DURATION, 0);
Bundle extras = new Bundle();
extras.putString("id", mediaId);
extras.putString("title", title);
extras.putString("artist", artist);
extras.putString("album", album);
extras.putString("uri", uri.toString());
extras.putString("type", Constants.MEDIA_TYPE_MUSIC);
extras.putInt("duration", duration);
MediaMetadata.Builder metadataBuilder = new MediaMetadata.Builder()
.setExtras(extras)
.setIsBrowsable(false)
.setIsPlayable(true);
if (!TextUtils.isEmpty(title)) metadataBuilder.setTitle(title);
if (!TextUtils.isEmpty(artist)) metadataBuilder.setArtist(artist);
if (!TextUtils.isEmpty(album)) metadataBuilder.setAlbumTitle(album);
MediaItem mediaItem = new MediaItem.Builder()
.setMediaId(mediaId)
.setMediaMetadata(metadataBuilder.build())
.setUri(uri)
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)
.setRequestMetadata(new MediaItem.RequestMetadata.Builder()
.setMediaUri(uri)
.setExtras(extras)
.build())
.build();
MediaManager.playDownloadedMediaItem(getMediaBrowserListenableFuture(), mediaItem);
}
}

View File

@@ -20,6 +20,7 @@ import com.cappielloantonio.tempo.util.MusicUtil;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAdapter.ViewHolder> implements Filterable {
@@ -152,12 +153,20 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAd
}
public void sort(String order) {
if (albums == null) return;
switch (order) {
case Constants.ALBUM_ORDER_BY_NAME:
albums.sort(Comparator.comparing(AlbumID3::getName));
albums.sort(Comparator.comparing(
album -> album.getName() != null ? album.getName() : "",
String.CASE_INSENSITIVE_ORDER
));
break;
case Constants.ALBUM_ORDER_BY_ARTIST:
albums.sort(Comparator.comparing(AlbumID3::getArtist, Comparator.nullsLast(Comparator.naturalOrder())));
albums.sort(Comparator.comparing(
album -> album.getArtist() != null ? album.getArtist() : "",
String.CASE_INSENSITIVE_ORDER
));
break;
case Constants.ALBUM_ORDER_BY_YEAR:
albums.sort(Comparator.comparing(AlbumID3::getYear));
@@ -166,15 +175,23 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAd
Collections.shuffle(albums);
break;
case Constants.ALBUM_ORDER_BY_RECENTLY_ADDED:
albums.sort(Comparator.comparing(AlbumID3::getCreated));
albums.sort(Comparator.comparing(
album -> album.getCreated() != null ? album.getCreated() : new Date(0),
Comparator.nullsLast(Date::compareTo)
));
Collections.reverse(albums);
break;
case Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED:
albums.sort(Comparator.comparing(AlbumID3::getPlayed));
albums.sort(Comparator.comparing(
album -> album.getPlayed() != null ? album.getPlayed() : new Date(0),
Comparator.nullsLast(Date::compareTo)
));
Collections.reverse(albums);
break;
case Constants.ALBUM_ORDER_BY_MOST_PLAYED:
albums.sort(Comparator.comparing(AlbumID3::getPlayCount));
albums.sort(Comparator.comparing(
album -> album.getPlayCount() != null ? album.getPlayCount() : 0L
));
Collections.reverse(albums);
break;
}

View File

@@ -191,7 +191,7 @@ public class DownloadHorizontalAdapter extends RecyclerView.Adapter<DownloadHori
R.string.song_subtitle_formatter,
song.getArtist(),
MusicUtil.getReadableDurationString(song.getDuration(), false),
""
MusicUtil.getReadableAudioQualityString(song)
)
);

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

@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog;
import android.os.Bundle;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment;
@@ -10,6 +11,7 @@ import androidx.media3.common.MediaMetadata;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.DialogTrackInfoBinding;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.util.AssetLinkUtil;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
@@ -21,6 +23,11 @@ public class TrackInfoDialog extends DialogFragment {
private DialogTrackInfoBinding bind;
private final MediaMetadata mediaMetadata;
private AssetLinkUtil.AssetLink songLink;
private AssetLinkUtil.AssetLink albumLink;
private AssetLinkUtil.AssetLink artistLink;
private AssetLinkUtil.AssetLink genreLink;
private AssetLinkUtil.AssetLink yearLink;
public TrackInfoDialog(MediaMetadata mediaMetadata) {
this.mediaMetadata = mediaMetadata;
@@ -52,6 +59,8 @@ public class TrackInfoDialog extends DialogFragment {
}
private void setTrackInfo() {
genreLink = null;
yearLink = null;
bind.trakTitleInfoTextView.setText(mediaMetadata.title);
bind.trakArtistInfoTextView.setText(
mediaMetadata.artist != null
@@ -61,17 +70,41 @@ public class TrackInfoDialog extends DialogFragment {
: "");
if (mediaMetadata.extras != null) {
songLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_SONG, mediaMetadata.extras.getString("id"));
albumLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ALBUM, mediaMetadata.extras.getString("albumId"));
artistLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ARTIST, mediaMetadata.extras.getString("artistId"));
genreLink = AssetLinkUtil.parseLinkString(mediaMetadata.extras.getString("assetLinkGenre"));
yearLink = AssetLinkUtil.parseLinkString(mediaMetadata.extras.getString("assetLinkYear"));
CustomGlideRequest.Builder
.from(requireContext(), mediaMetadata.extras.getString("coverArtId", ""), CustomGlideRequest.ResourceType.Song)
.build()
.into(bind.trackCoverInfoImageView);
bind.titleValueSector.setText(mediaMetadata.extras.getString("title", getString(R.string.label_placeholder)));
bind.albumValueSector.setText(mediaMetadata.extras.getString("album", getString(R.string.label_placeholder)));
bind.artistValueSector.setText(mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder)));
bindAssetLink(bind.trackCoverInfoImageView, albumLink != null ? albumLink : songLink);
bindAssetLink(bind.trakTitleInfoTextView, songLink);
bindAssetLink(bind.trakArtistInfoTextView, artistLink != null ? artistLink : songLink);
String titleValue = mediaMetadata.extras.getString("title", getString(R.string.label_placeholder));
String albumValue = mediaMetadata.extras.getString("album", getString(R.string.label_placeholder));
String artistValue = mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder));
String genreValue = mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder));
int yearValue = mediaMetadata.extras.getInt("year", 0);
if (genreLink == null && genreValue != null && !genreValue.isEmpty() && !getString(R.string.label_placeholder).contentEquals(genreValue)) {
genreLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_GENRE, genreValue);
}
if (yearLink == null && yearValue != 0) {
yearLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(yearValue));
}
bind.titleValueSector.setText(titleValue);
bind.albumValueSector.setText(albumValue);
bind.artistValueSector.setText(artistValue);
bind.trackNumberValueSector.setText(mediaMetadata.extras.getInt("track", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("track", 0)) : getString(R.string.label_placeholder));
bind.yearValueSector.setText(mediaMetadata.extras.getInt("year", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("year", 0)) : getString(R.string.label_placeholder));
bind.genreValueSector.setText(mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder)));
bind.yearValueSector.setText(yearValue != 0 ? String.valueOf(yearValue) : getString(R.string.label_placeholder));
bind.genreValueSector.setText(genreValue);
bind.sizeValueSector.setText(mediaMetadata.extras.getLong("size", 0) != 0 ? MusicUtil.getReadableByteCount(mediaMetadata.extras.getLong("size", 0)) : getString(R.string.label_placeholder));
bind.contentTypeValueSector.setText(mediaMetadata.extras.getString("contentType", getString(R.string.label_placeholder)));
bind.suffixValueSector.setText(mediaMetadata.extras.getString("suffix", getString(R.string.label_placeholder)));
@@ -83,6 +116,12 @@ public class TrackInfoDialog extends DialogFragment {
bind.bitDepthValueSector.setText(mediaMetadata.extras.getInt("bitDepth", 0) != 0 ? mediaMetadata.extras.getInt("bitDepth", 0) + " bits" : getString(R.string.label_placeholder));
bind.pathValueSector.setText(mediaMetadata.extras.getString("path", getString(R.string.label_placeholder)));
bind.discNumberValueSector.setText(mediaMetadata.extras.getInt("discNumber", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("discNumber", 0)) : getString(R.string.label_placeholder));
bindAssetLink(bind.titleValueSector, songLink);
bindAssetLink(bind.albumValueSector, albumLink);
bindAssetLink(bind.artistValueSector, artistLink);
bindAssetLink(bind.genreValueSector, genreLink);
bindAssetLink(bind.yearValueSector, yearLink);
}
}
@@ -135,4 +174,31 @@ public class TrackInfoDialog extends DialogFragment {
bind.trakTranscodingInfoTextView.setText(info);
}
}
private void bindAssetLink(android.view.View view, AssetLinkUtil.AssetLink assetLink) {
if (view == null) return;
if (assetLink == null) {
AssetLinkUtil.clearLinkAppearance(view);
view.setOnClickListener(null);
view.setOnLongClickListener(null);
view.setClickable(false);
view.setLongClickable(false);
return;
}
view.setClickable(true);
view.setLongClickable(true);
AssetLinkUtil.applyLinkAppearance(view);
view.setOnClickListener(v -> {
dismissAllowingStateLoss();
boolean collapse = !AssetLinkUtil.TYPE_SONG.equals(assetLink.type);
((com.cappielloantonio.tempo.ui.activity.MainActivity) requireActivity()).openAssetLink(assetLink, collapse);
});
view.setOnLongClickListener(v -> {
AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, assetLink.id), Toast.LENGTH_SHORT).show();
return true;
});
}
}

View File

@@ -32,17 +32,19 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.AlbumCatalogueViewModel;
@OptIn(markerClass = UnstableApi.class)
public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
private static final String TAG = "ArtistCatalogueFragment";
private static final String TAG = "AlbumCatalogueFragment";
private FragmentAlbumCatalogueBinding bind;
private MainActivity activity;
private AlbumCatalogueViewModel albumCatalogueViewModel;
private AlbumCatalogueAdapter albumAdapter;
private String currentSortOrder;
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
@@ -115,7 +117,10 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
albumAdapter = new AlbumCatalogueAdapter(this, true);
albumAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY);
bind.albumCatalogueRecyclerView.setAdapter(albumAdapter);
albumCatalogueViewModel.getAlbumList().observe(getViewLifecycleOwner(), albums -> albumAdapter.setItems(albums));
albumCatalogueViewModel.getAlbumList().observe(getViewLifecycleOwner(), albums -> {
albumAdapter.setItems(albums);
applySavedSortOrder();
});
bind.albumCatalogueRecyclerView.setOnTouchListener((v, event) -> {
hideKeyboard(v);
@@ -137,6 +142,44 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
});
}
private void applySavedSortOrder() {
String savedSortOrder = Preferences.getAlbumSortOrder();
currentSortOrder = savedSortOrder;
albumAdapter.sort(savedSortOrder);
updateSortIndicator();
}
private void updateSortIndicator() {
if (bind == null) return;
String sortText = getSortDisplayText(currentSortOrder);
bind.albumListSortTextView.setText(sortText);
bind.albumListSortTextView.setVisibility(View.VISIBLE);
}
private String getSortDisplayText(String sortOrder) {
if (sortOrder == null) return "";
switch (sortOrder) {
case Constants.ALBUM_ORDER_BY_NAME:
return getString(R.string.menu_sort_name);
case Constants.ALBUM_ORDER_BY_ARTIST:
return getString(R.string.menu_group_by_artist);
case Constants.ALBUM_ORDER_BY_YEAR:
return getString(R.string.menu_sort_year);
case Constants.ALBUM_ORDER_BY_RANDOM:
return getString(R.string.menu_sort_random);
case Constants.ALBUM_ORDER_BY_RECENTLY_ADDED:
return getString(R.string.menu_sort_recently_added);
case Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED:
return getString(R.string.menu_sort_recently_played);
case Constants.ALBUM_ORDER_BY_MOST_PLAYED:
return getString(R.string.menu_sort_most_played);
default:
return "";
}
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
inflater.inflate(R.menu.toolbar_menu, menu);
@@ -172,26 +215,29 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
popup.getMenuInflater().inflate(menuResource, popup.getMenu());
popup.setOnMenuItemClickListener(menuItem -> {
String newSortOrder = null;
if (menuItem.getItemId() == R.id.menu_album_sort_name) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_NAME);
return true;
newSortOrder = Constants.ALBUM_ORDER_BY_NAME;
} else if (menuItem.getItemId() == R.id.menu_album_sort_artist) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_ARTIST);
return true;
newSortOrder = Constants.ALBUM_ORDER_BY_ARTIST;
} else if (menuItem.getItemId() == R.id.menu_album_sort_year) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_YEAR);
return true;
newSortOrder = Constants.ALBUM_ORDER_BY_YEAR;
} else if (menuItem.getItemId() == R.id.menu_album_sort_random) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_RANDOM);
return true;
newSortOrder = Constants.ALBUM_ORDER_BY_RANDOM;
} else if (menuItem.getItemId() == R.id.menu_album_sort_recently_added) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_RECENTLY_ADDED);
return true;
newSortOrder = Constants.ALBUM_ORDER_BY_RECENTLY_ADDED;
} else if (menuItem.getItemId() == R.id.menu_album_sort_recently_played) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED);
return true;
newSortOrder = Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED;
} else if (menuItem.getItemId() == R.id.menu_album_sort_most_played) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_MOST_PLAYED);
newSortOrder = Constants.ALBUM_ORDER_BY_MOST_PLAYED;
}
if (newSortOrder != null) {
currentSortOrder = newSortOrder;
albumAdapter.sort(newSortOrder);
Preferences.setAlbumSortOrder(newSortOrder);
updateSortIndicator();
return true;
}

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,22 @@ 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.AssetLinkUtil;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.AlbumPageViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
@@ -49,6 +56,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
private FragmentAlbumPageBinding bind;
private MainActivity activity;
private AlbumPageViewModel albumPageViewModel;
private PlaybackViewModel playbackViewModel;
private SongHorizontalAdapter songHorizontalAdapter;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@@ -71,6 +79,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind = FragmentAlbumPageBinding.inflate(inflater, container, false);
View view = bind.getRoot();
albumPageViewModel = new ViewModelProvider(requireActivity()).get(AlbumPageViewModel.class);
playbackViewModel = new ViewModelProvider(requireActivity()).get(PlaybackViewModel.class);
init();
initAppBar();
@@ -88,6 +97,14 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
super.onStart();
initializeMediaBrowser();
MediaManager.registerPlaybackObserver(mediaBrowserListenableFuture, playbackViewModel);
observePlayback();
}
public void onResume() {
super.onResume();
if (songHorizontalAdapter != null) setMediaBrowserListenableFuture();
}
@Override
@@ -104,9 +121,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;
}
@@ -144,8 +178,35 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind.albumNameLabel.setText(album.getName());
bind.albumArtistLabel.setText(album.getArtist());
AssetLinkUtil.applyLinkAppearance(bind.albumArtistLabel);
AssetLinkUtil.AssetLink artistLink = buildArtistLink(album);
bind.albumArtistLabel.setOnLongClickListener(v -> {
if (artistLink != null) {
AssetLinkUtil.copyToClipboard(requireContext(), artistLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, artistLink.id), Toast.LENGTH_SHORT).show();
return true;
}
return false;
});
bind.albumReleaseYearLabel.setText(album.getYear() != 0 ? String.valueOf(album.getYear()) : "");
bind.albumReleaseYearLabel.setVisibility(album.getYear() != 0 ? View.VISIBLE : View.GONE);
if (album.getYear() != 0) {
bind.albumReleaseYearLabel.setVisibility(View.VISIBLE);
AssetLinkUtil.applyLinkAppearance(bind.albumReleaseYearLabel);
bind.albumReleaseYearLabel.setOnClickListener(v -> openYearLink(album.getYear()));
bind.albumReleaseYearLabel.setOnLongClickListener(v -> {
AssetLinkUtil.AssetLink yearLink = buildYearLink(album.getYear());
if (yearLink != null) {
AssetLinkUtil.copyToClipboard(requireContext(), yearLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, yearLink.id), Toast.LENGTH_SHORT).show();
}
return true;
});
} else {
bind.albumReleaseYearLabel.setVisibility(View.GONE);
bind.albumReleaseYearLabel.setOnClickListener(null);
bind.albumReleaseYearLabel.setOnLongClickListener(null);
AssetLinkUtil.clearLinkAppearance(bind.albumReleaseYearLabel);
}
bind.albumSongCountDurationTextview.setText(getString(R.string.album_page_tracks_count_and_duration, album.getSongCount(), album.getDuration() != null ? album.getDuration() / 60 : 0));
if (album.getGenre() != null && !album.getGenre().isEmpty()) {
bind.albumGenresTextview.setText(album.getGenre());
@@ -256,10 +317,15 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.songRecyclerView.setHasFixedSize(true);
songHorizontalAdapter = new SongHorizontalAdapter(this, false, false, album);
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, false, false, album);
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture();
reapplyPlayback();
albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> songHorizontalAdapter.setItems(songs));
albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> {
songHorizontalAdapter.setItems(songs);
reapplyPlayback();
});
}
});
}
@@ -282,4 +348,50 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
public void onMediaLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.songBottomSheetDialog, bundle);
}
}
private void observePlayback() {
playbackViewModel.getCurrentSongId().observe(getViewLifecycleOwner(), id -> {
if (songHorizontalAdapter != null) {
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
playbackViewModel.getIsPlaying().observe(getViewLifecycleOwner(), playing -> {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
});
}
private void reapplyPlayback() {
if (songHorizontalAdapter != null) {
String id = playbackViewModel.getCurrentSongId().getValue();
Boolean playing = playbackViewModel.getIsPlaying().getValue();
songHorizontalAdapter.setPlaybackState(id, playing != null && playing);
}
}
private void setMediaBrowserListenableFuture() {
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
}
private void openYearLink(int year) {
AssetLinkUtil.AssetLink link = buildYearLink(year);
if (link != null) {
activity.openAssetLink(link);
}
}
private AssetLinkUtil.AssetLink buildYearLink(int year) {
if (year <= 0) return null;
return AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(year));
}
private AssetLinkUtil.AssetLink buildArtistLink(AlbumID3 album) {
if (album == null || album.getArtistId() == null || album.getArtistId().isEmpty()) {
return null;
}
return AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ARTIST, album.getArtistId());
}
}

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

@@ -195,6 +195,7 @@ public class PlayerBottomSheetFragment extends Fragment {
}
}
private void setMediaControllerUI(MediaBrowser mediaBrowser) {
if (mediaBrowser.getMediaMetadata().extras != null) {
switch (mediaBrowser.getMediaMetadata().extras.getString("type", Constants.MEDIA_TYPE_MUSIC)) {

View File

@@ -1,16 +1,22 @@
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.RatingBar;
import android.widget.TextView;
import android.widget.ToggleButton;
import android.widget.RatingBar;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.constraintlayout.widget.ConstraintLayout;
@@ -23,22 +29,27 @@ import androidx.media3.common.util.RepeatModeUtil;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.media3.session.SessionToken;
import androidx.navigation.NavController;
import androidx.navigation.NavOptions;
import androidx.navigation.fragment.NavHostFragment;
import androidx.viewpager2.widget.ViewPager2;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.InnerFragmentPlayerControllerBinding;
import com.cappielloantonio.tempo.service.EqualizerManager;
import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
import com.cappielloantonio.tempo.ui.dialog.TrackInfoDialog;
import com.cappielloantonio.tempo.ui.fragment.pager.PlayerControllerHorizontalPager;
import com.cappielloantonio.tempo.util.AssetLinkUtil;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.cappielloantonio.tempo.viewmodel.RatingViewModel;
import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
import com.google.android.material.elevation.SurfaceColors;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
@@ -66,11 +77,20 @@ public class PlayerControllerFragment extends Fragment {
private ConstraintLayout playerQuickActionView;
private ImageButton playerOpenQueueButton;
private ImageButton playerTrackInfo;
private LinearLayout ratingContainer;
private ImageButton equalizerButton;
private ChipGroup assetLinkChipGroup;
private Chip playerSongLinkChip;
private Chip playerAlbumLinkChip;
private Chip playerArtistLinkChip;
private MainActivity activity;
private PlayerBottomSheetViewModel playerBottomSheetViewModel;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private MediaService.LocalBinder mediaServiceBinder;
private boolean isServiceBound = false;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
activity = (MainActivity) getActivity();
@@ -87,6 +107,7 @@ public class PlayerControllerFragment extends Fragment {
initMediaListenable();
initMediaLabelButton();
initArtistLabelButton();
initEqualizerButton();
return view;
}
@@ -123,6 +144,13 @@ public class PlayerControllerFragment extends Fragment {
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);
assetLinkChipGroup = bind.getRoot().findViewById(R.id.asset_link_chip_group);
playerSongLinkChip = bind.getRoot().findViewById(R.id.asset_link_song_chip);
playerAlbumLinkChip = bind.getRoot().findViewById(R.id.asset_link_album_chip);
playerArtistLinkChip = bind.getRoot().findViewById(R.id.asset_link_artist_chip);
checkAndSetRatingContainerVisibility();
}
private void initQuickActionView() {
@@ -202,6 +230,8 @@ public class PlayerControllerFragment extends Fragment {
|| mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) && mediaMetadata.extras.getString("uri") != null
? View.VISIBLE
: View.GONE);
updateAssetLinkChips(mediaMetadata);
}
private void setMediaInfo(MediaMetadata mediaMetadata) {
@@ -242,6 +272,110 @@ public class PlayerControllerFragment extends Fragment {
});
}
private void updateAssetLinkChips(MediaMetadata mediaMetadata) {
if (assetLinkChipGroup == null) return;
String mediaType = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type", Constants.MEDIA_TYPE_MUSIC) : Constants.MEDIA_TYPE_MUSIC;
if (!Constants.MEDIA_TYPE_MUSIC.equals(mediaType)) {
clearAssetLinkChip(playerSongLinkChip);
clearAssetLinkChip(playerAlbumLinkChip);
clearAssetLinkChip(playerArtistLinkChip);
syncAssetLinkGroupVisibility();
return;
}
String songId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("id") : null;
String albumId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("albumId") : null;
String artistId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("artistId") : null;
AssetLinkUtil.AssetLink songLink = bindAssetLinkChip(playerSongLinkChip, AssetLinkUtil.TYPE_SONG, songId);
AssetLinkUtil.AssetLink albumLink = bindAssetLinkChip(playerAlbumLinkChip, AssetLinkUtil.TYPE_ALBUM, albumId);
AssetLinkUtil.AssetLink artistLink = bindAssetLinkChip(playerArtistLinkChip, AssetLinkUtil.TYPE_ARTIST, artistId);
bindAssetLinkView(playerMediaTitleLabel, songLink);
bindAssetLinkView(playerArtistNameLabel, artistLink != null ? artistLink : songLink);
bindAssetLinkView(playerMediaCoverViewPager, songLink);
syncAssetLinkGroupVisibility();
}
private AssetLinkUtil.AssetLink bindAssetLinkChip(Chip chip, String type, String id) {
if (chip == null) return null;
if (TextUtils.isEmpty(id)) {
clearAssetLinkChip(chip);
return null;
}
String label = getString(AssetLinkUtil.getLabelRes(type));
AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.buildAssetLink(type, id);
if (assetLink == null) {
clearAssetLinkChip(chip);
return null;
}
chip.setText(getString(R.string.asset_link_chip_text, label, assetLink.id));
chip.setVisibility(View.VISIBLE);
chip.setOnClickListener(v -> {
if (assetLink != null) {
activity.openAssetLink(assetLink);
}
});
chip.setOnLongClickListener(v -> {
if (assetLink != null) {
AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, id), Toast.LENGTH_SHORT).show();
}
return true;
});
return assetLink;
}
private void clearAssetLinkChip(Chip chip) {
if (chip == null) return;
chip.setVisibility(View.GONE);
chip.setText("");
chip.setOnClickListener(null);
chip.setOnLongClickListener(null);
}
private void bindAssetLinkView(View view, AssetLinkUtil.AssetLink assetLink) {
if (view == null) return;
if (assetLink == null) {
AssetLinkUtil.clearLinkAppearance(view);
view.setOnClickListener(null);
view.setOnLongClickListener(null);
view.setClickable(false);
view.setLongClickable(false);
return;
}
view.setClickable(true);
view.setLongClickable(true);
AssetLinkUtil.applyLinkAppearance(view);
view.setOnClickListener(v -> {
boolean collapse = !AssetLinkUtil.TYPE_SONG.equals(assetLink.type);
activity.openAssetLink(assetLink, collapse);
});
view.setOnLongClickListener(v -> {
AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, assetLink.id), Toast.LENGTH_SHORT).show();
return true;
});
}
private void syncAssetLinkGroupVisibility() {
if (assetLinkChipGroup == null) return;
boolean hasVisible = false;
for (int i = 0; i < assetLinkChipGroup.getChildCount(); i++) {
View child = assetLinkChipGroup.getChildAt(i);
if (child.getVisibility() == View.VISIBLE) {
hasVisible = true;
break;
}
}
assetLinkChipGroup.setVisibility(hasVisible ? View.VISIBLE : View.GONE);
}
private void setMediaControllerUI(MediaBrowser mediaBrowser) {
initPlaybackSpeedButton(mediaBrowser);
@@ -422,6 +556,18 @@ public class PlayerControllerFragment extends Fragment {
});
}
private void initEqualizerButton() {
equalizerButton.setOnClickListener(v -> {
NavController navController = NavHostFragment.findNavController(this);
NavOptions navOptions = new NavOptions.Builder()
.setLaunchSingleTop(true)
.setPopUpTo(R.id.equalizerFragment, true)
.build();
navController.navigate(R.id.equalizerFragment, null, navOptions);
if (activity != null) activity.collapseBottomSheetDelayed();
});
}
public void goToControllerPage() {
playerMediaCoverViewPager.setCurrentItem(0, false);
}
@@ -430,6 +576,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();
@@ -446,4 +603,66 @@ public class PlayerControllerFragment extends Fragment {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_100));
// TODO Resettare lo skip del silenzio
}
}
private final ServiceConnection serviceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
mediaServiceBinder = (MediaService.LocalBinder) service;
isServiceBound = true;
checkEqualizerBands();
}
@Override
public void onServiceDisconnected(ComponentName name) {
mediaServiceBinder = null;
isServiceBound = false;
}
};
private void bindMediaService() {
Intent intent = new Intent(requireActivity(), MediaService.class);
intent.setAction(MediaService.ACTION_BIND_EQUALIZER);
requireActivity().bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
isServiceBound = true;
}
private void checkEqualizerBands() {
if (mediaServiceBinder != null) {
EqualizerManager eqManager = mediaServiceBinder.getEqualizerManager();
short numBands = eqManager.getNumberOfBands();
if (equalizerButton != null) {
if (numBands == 0) {
equalizerButton.setVisibility(View.GONE);
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) playerOpenQueueButton.getLayoutParams();
params.startToEnd = ConstraintLayout.LayoutParams.UNSET;
params.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
playerOpenQueueButton.setLayoutParams(params);
} else {
equalizerButton.setVisibility(View.VISIBLE);
ConstraintLayout.LayoutParams params = (ConstraintLayout.LayoutParams) playerOpenQueueButton.getLayoutParams();
params.startToStart = ConstraintLayout.LayoutParams.UNSET;
params.startToEnd = R.id.player_open_equalizer_button;
playerOpenQueueButton.setLayoutParams(params);
}
}
}
}
@Override
public void onResume() {
super.onResume();
bindMediaService();
}
@Override
public void onPause() {
super.onPause();
if (isServiceBound) {
requireActivity().unbindService(serviceConnection);
isServiceBound = false;
}
}
}

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(), R.string.settings_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(R.string.settings_clear_download_folder);
directory.setSummary(current);
} else {
if (storage != null) storage.setVisible(true);
if (Preferences.getDownloadStoragePreference() == 2) {
directory.setVisible(true);
directory.setIcon(R.drawable.ic_folder);
directory.setTitle(R.string.settings_set_download_folder);
directory.setSummary(R.string.settings_choose_download_folder);
} else {
directory.setVisible(false);
}
}
}
private void setStreamingCacheSize() {
ListPreference streamingCachePreference = findPreference("streaming_cache_size");
@@ -242,7 +325,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
@Override
public void onSuccess(boolean isScanning, long count) {
findPreference("scan_library").setSummary("Scanning: counting " + count + " tracks");
findPreference("scan_library").setSummary(getString(R.string.settings_scan_result, count));
if (isScanning) getScanStatus();
}
});
@@ -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(), R.string.settings_download_folder_cleared, Toast.LENGTH_SHORT).show();
checkStorage();
checkDownloadDirectory();
} else {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
directoryPickerLauncher.launch(intent);
}
return true;
});
}
}
private void actionDeleteDownloadStorage() {
findPreference("delete_download_storage").setOnPreferenceClickListener(preference -> {
DeleteDownloadStorageDialog dialog = new DeleteDownloadStorageDialog();
@@ -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
@@ -316,7 +492,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
@Override
public void onSuccess(boolean isScanning, long count) {
findPreference("scan_library").setSummary("Scanning: counting " + count + " tracks");
findPreference("scan_library").setSummary(getString(R.string.settings_scan_result, count));
if (isScanning) getScanStatus();
}
});
@@ -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;
@@ -29,16 +30,24 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
import com.cappielloantonio.tempo.util.AssetLinkUtil;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
import com.google.common.util.concurrent.ListenableFuture;
import android.content.Intent;
import androidx.media3.common.MediaItem;
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import java.util.ArrayList;
import java.util.Collections;
@@ -48,6 +57,16 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
private SongBottomSheetViewModel songBottomSheetViewModel;
private Child song;
private TextView downloadButton;
private TextView removeButton;
private ChipGroup assetLinkChipGroup;
private Chip songLinkChip;
private Chip albumLinkChip;
private Chip artistLinkChip;
private AssetLinkUtil.AssetLink currentSongLink;
private AssetLinkUtil.AssetLink currentAlbumLink;
private AssetLinkUtil.AssetLink currentArtistLink;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@Nullable
@@ -66,6 +85,12 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
return view;
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateDownloadButtons);
}
@Override
public void onStart() {
super.onStart();
@@ -94,6 +119,11 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
TextView artistSong = view.findViewById(R.id.song_artist_text_view);
artistSong.setText(songBottomSheetViewModel.getSong().getArtist());
initAssetLinkChips(view);
bindAssetLinkView(coverSong, currentSongLink);
bindAssetLinkView(titleSong, currentSongLink);
bindAssetLinkView(artistSong, currentArtistLink != null ? currentArtistLink : currentSongLink);
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
favoriteToggle.setChecked(songBottomSheetViewModel.getSong().getStarred() != null);
favoriteToggle.setOnClickListener(v -> {
@@ -157,25 +187,33 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
dismissBottomSheet();
});
TextView download = view.findViewById(R.id.download_text_view);
download.setOnClickListener(v -> {
DownloadUtil.getDownloadTracker(requireContext()).download(
MappingUtil.mapDownload(song),
new Download(song)
);
downloadButton = view.findViewById(R.id.download_text_view);
downloadButton.setOnClickListener(v -> {
if (Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(requireContext()).download(
MappingUtil.mapDownload(song),
new Download(song)
);
} else {
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
}
dismissBottomSheet();
});
TextView remove = view.findViewById(R.id.remove_text_view);
remove.setOnClickListener(v -> {
DownloadUtil.getDownloadTracker(requireContext()).remove(
MappingUtil.mapDownload(song),
new Download(song)
);
removeButton = view.findViewById(R.id.remove_text_view);
removeButton.setOnClickListener(v -> {
if (Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(requireContext()).remove(
MappingUtil.mapDownload(song),
new Download(song)
);
} else {
ExternalAudioReader.delete(song);
}
dismissBottomSheet();
});
initDownloadUI(download, remove);
updateDownloadButtons();
TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view);
addToPlaylist.setOnClickListener(v -> {
@@ -243,13 +281,109 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
dismiss();
}
private void initDownloadUI(TextView download, TextView remove) {
if (DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId())) {
remove.setVisibility(View.VISIBLE);
} else {
download.setVisibility(View.VISIBLE);
remove.setVisibility(View.GONE);
private void updateDownloadButtons() {
if (downloadButton == null || removeButton == null) {
return;
}
if (Preferences.getDownloadDirectoryUri() == null) {
boolean downloaded = DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId());
downloadButton.setVisibility(downloaded ? View.GONE : View.VISIBLE);
removeButton.setVisibility(downloaded ? View.VISIBLE : View.GONE);
} else {
boolean hasLocal = ExternalAudioReader.getUri(song) != null;
downloadButton.setVisibility(hasLocal ? View.GONE : View.VISIBLE);
removeButton.setVisibility(hasLocal ? View.VISIBLE : View.GONE);
}
}
private void initAssetLinkChips(View root) {
assetLinkChipGroup = root.findViewById(R.id.asset_link_chip_group);
songLinkChip = root.findViewById(R.id.asset_link_song_chip);
albumLinkChip = root.findViewById(R.id.asset_link_album_chip);
artistLinkChip = root.findViewById(R.id.asset_link_artist_chip);
currentSongLink = bindAssetLinkChip(songLinkChip, AssetLinkUtil.TYPE_SONG, song.getId());
currentAlbumLink = bindAssetLinkChip(albumLinkChip, AssetLinkUtil.TYPE_ALBUM, song.getAlbumId());
currentArtistLink = bindAssetLinkChip(artistLinkChip, AssetLinkUtil.TYPE_ARTIST, song.getArtistId());
syncAssetLinkGroupVisibility();
}
private AssetLinkUtil.AssetLink bindAssetLinkChip(@Nullable Chip chip, String type, @Nullable String id) {
if (chip == null) return null;
if (id == null || id.isEmpty()) {
clearAssetLinkChip(chip);
return null;
}
String label = getString(AssetLinkUtil.getLabelRes(type));
AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.buildAssetLink(type, id);
if (assetLink == null) {
clearAssetLinkChip(chip);
return null;
}
chip.setText(getString(R.string.asset_link_chip_text, label, assetLink.id));
chip.setVisibility(View.VISIBLE);
chip.setOnClickListener(v -> {
if (assetLink != null) {
((MainActivity) requireActivity()).openAssetLink(assetLink);
}
});
chip.setOnLongClickListener(v -> {
if (assetLink != null) {
AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, id), Toast.LENGTH_SHORT).show();
}
return true;
});
return assetLink;
}
private void clearAssetLinkChip(@Nullable Chip chip) {
if (chip == null) return;
chip.setVisibility(View.GONE);
chip.setText("");
chip.setOnClickListener(null);
chip.setOnLongClickListener(null);
}
private void syncAssetLinkGroupVisibility() {
if (assetLinkChipGroup == null) return;
boolean hasVisible = false;
for (int i = 0; i < assetLinkChipGroup.getChildCount(); i++) {
View child = assetLinkChipGroup.getChildAt(i);
if (child.getVisibility() == View.VISIBLE) {
hasVisible = true;
break;
}
}
assetLinkChipGroup.setVisibility(hasVisible ? View.VISIBLE : View.GONE);
}
private void bindAssetLinkView(@Nullable View view, @Nullable AssetLinkUtil.AssetLink assetLink) {
if (view == null) return;
if (assetLink == null) {
AssetLinkUtil.clearLinkAppearance(view);
view.setOnClickListener(null);
view.setOnLongClickListener(null);
view.setClickable(false);
view.setLongClickable(false);
return;
}
view.setClickable(true);
view.setLongClickable(true);
AssetLinkUtil.applyLinkAppearance(view);
view.setOnClickListener(v -> ((MainActivity) requireActivity()).openAssetLink(assetLink, !AssetLinkUtil.TYPE_SONG.equals(assetLink.type)));
view.setOnLongClickListener(v -> {
AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, assetLink.id), Toast.LENGTH_SHORT).show();
return true;
});
}
private void initializeMediaBrowser() {
@@ -263,4 +397,4 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
private void refreshShares() {
homeViewModel.refreshShares(requireActivity());
}
}
}

View File

@@ -0,0 +1,188 @@
package com.cappielloantonio.tempo.util;
import android.os.Bundle;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavController;
import androidx.navigation.NavOptions;
import com.cappielloantonio.tempo.BuildConfig;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.PlaylistRepository;
import com.cappielloantonio.tempo.repository.SongRepository;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Playlist;
import com.cappielloantonio.tempo.subsonic.models.Genre;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.fragment.bottomsheetdialog.SongBottomSheetDialog;
import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel;
public final class AssetLinkNavigator {
private final MainActivity activity;
private final SongRepository songRepository = new SongRepository();
private final AlbumRepository albumRepository = new AlbumRepository();
private final ArtistRepository artistRepository = new ArtistRepository();
private final PlaylistRepository playlistRepository = new PlaylistRepository();
public AssetLinkNavigator(@NonNull MainActivity activity) {
this.activity = activity;
}
public void open(@Nullable AssetLinkUtil.AssetLink assetLink) {
if (assetLink == null) {
return;
}
switch (assetLink.type) {
case AssetLinkUtil.TYPE_SONG:
openSong(assetLink.id);
break;
case AssetLinkUtil.TYPE_ALBUM:
openAlbum(assetLink.id);
break;
case AssetLinkUtil.TYPE_ARTIST:
openArtist(assetLink.id);
break;
case AssetLinkUtil.TYPE_PLAYLIST:
openPlaylist(assetLink.id);
break;
case AssetLinkUtil.TYPE_GENRE:
openGenre(assetLink.id);
break;
case AssetLinkUtil.TYPE_YEAR:
openYear(assetLink.id);
break;
default:
Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show();
break;
}
}
private void openSong(@NonNull String id) {
MutableLiveData<Child> liveData = songRepository.getSong(id);
Observer<Child> observer = new Observer<Child>() {
@Override
public void onChanged(Child child) {
liveData.removeObserver(this);
if (child == null) {
Toast.makeText(activity, R.string.asset_link_error_song, Toast.LENGTH_SHORT).show();
return;
}
SongBottomSheetViewModel viewModel = new ViewModelProvider(activity).get(SongBottomSheetViewModel.class);
viewModel.setSong(child);
SongBottomSheetDialog dialog = new SongBottomSheetDialog();
Bundle args = new Bundle();
args.putParcelable(Constants.TRACK_OBJECT, child);
dialog.setArguments(args);
dialog.show(activity.getSupportFragmentManager(), null);
}
};
liveData.observe(activity, observer);
}
private void openAlbum(@NonNull String id) {
MutableLiveData<AlbumID3> liveData = albumRepository.getAlbum(id);
Observer<AlbumID3> observer = new Observer<AlbumID3>() {
@Override
public void onChanged(AlbumID3 album) {
liveData.removeObserver(this);
if (album == null) {
Toast.makeText(activity, R.string.asset_link_error_album, Toast.LENGTH_SHORT).show();
return;
}
Bundle args = new Bundle();
args.putParcelable(Constants.ALBUM_OBJECT, album);
navigateSafely(R.id.albumPageFragment, args);
}
};
liveData.observe(activity, observer);
}
private void openArtist(@NonNull String id) {
MutableLiveData<ArtistID3> liveData = artistRepository.getArtist(id);
Observer<ArtistID3> observer = new Observer<ArtistID3>() {
@Override
public void onChanged(ArtistID3 artist) {
liveData.removeObserver(this);
if (artist == null) {
Toast.makeText(activity, R.string.asset_link_error_artist, Toast.LENGTH_SHORT).show();
return;
}
Bundle args = new Bundle();
args.putParcelable(Constants.ARTIST_OBJECT, artist);
navigateSafely(R.id.artistPageFragment, args);
}
};
liveData.observe(activity, observer);
}
private void openPlaylist(@NonNull String id) {
MutableLiveData<Playlist> liveData = playlistRepository.getPlaylist(id);
Observer<Playlist> observer = new Observer<Playlist>() {
@Override
public void onChanged(Playlist playlist) {
liveData.removeObserver(this);
if (playlist == null) {
Toast.makeText(activity, R.string.asset_link_error_playlist, Toast.LENGTH_SHORT).show();
return;
}
Bundle args = new Bundle();
args.putParcelable(Constants.PLAYLIST_OBJECT, playlist);
navigateSafely(R.id.playlistPageFragment, args);
}
};
liveData.observe(activity, observer);
}
private void openGenre(@NonNull String genreName) {
String trimmed = genreName.trim();
if (trimmed.isEmpty()) {
Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show();
return;
}
Genre genre = new Genre();
genre.setGenre(trimmed);
genre.setSongCount(0);
genre.setAlbumCount(0);
Bundle args = new Bundle();
args.putParcelable(Constants.GENRE_OBJECT, genre);
args.putString(Constants.MEDIA_BY_GENRE, Constants.MEDIA_BY_GENRE);
navigateSafely(R.id.songListPageFragment, args);
}
private void openYear(@NonNull String yearValue) {
try {
int year = Integer.parseInt(yearValue.trim());
Bundle args = new Bundle();
args.putInt("year_object", year);
args.putString(Constants.MEDIA_BY_YEAR, Constants.MEDIA_BY_YEAR);
navigateSafely(R.id.songListPageFragment, args);
} catch (NumberFormatException ex) {
Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show();
}
}
private void navigateSafely(int destinationId, @Nullable Bundle args) {
activity.runOnUiThread(() -> {
NavController navController = activity.navController;
if (navController == null) {
return;
}
if (navController.getCurrentDestination() != null
&& navController.getCurrentDestination().getId() == destinationId) {
navController.navigate(destinationId, args, new NavOptions.Builder().setLaunchSingleTop(true).build());
} else {
navController.navigate(destinationId, args);
}
});
}
}

View File

@@ -0,0 +1,188 @@
package com.cappielloantonio.tempo.util;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.text.TextUtils;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.content.ContextCompat;
import com.cappielloantonio.tempo.R;
import java.util.Objects;
import com.google.android.material.color.MaterialColors;
public final class AssetLinkUtil {
public static final String SCHEME = "tempo";
public static final String HOST_ASSET = "asset";
public static final String TYPE_SONG = "song";
public static final String TYPE_ALBUM = "album";
public static final String TYPE_ARTIST = "artist";
public static final String TYPE_PLAYLIST = "playlist";
public static final String TYPE_GENRE = "genre";
public static final String TYPE_YEAR = "year";
private AssetLinkUtil() {
}
@Nullable
public static AssetLink parse(@Nullable Intent intent) {
if (intent == null) return null;
return parse(intent.getData());
}
@Nullable
public static AssetLink parse(@Nullable Uri uri) {
if (uri == null) {
return null;
}
if (!SCHEME.equalsIgnoreCase(uri.getScheme())) {
return null;
}
String host = uri.getHost();
if (!HOST_ASSET.equalsIgnoreCase(host)) {
return null;
}
if (uri.getPathSegments().size() < 2) {
return null;
}
String type = uri.getPathSegments().get(0);
String id = uri.getPathSegments().get(1);
if (TextUtils.isEmpty(type) || TextUtils.isEmpty(id)) {
return null;
}
if (!isSupportedType(type)) {
return null;
}
return new AssetLink(type, id, uri);
}
public static boolean isSupportedType(@Nullable String type) {
if (type == null) return false;
switch (type) {
case TYPE_SONG:
case TYPE_ALBUM:
case TYPE_ARTIST:
case TYPE_PLAYLIST:
case TYPE_GENRE:
case TYPE_YEAR:
return true;
default:
return false;
}
}
@NonNull
public static Uri buildUri(@NonNull String type, @NonNull String id) {
return new Uri.Builder()
.scheme(SCHEME)
.authority(HOST_ASSET)
.appendPath(type)
.appendPath(id)
.build();
}
@Nullable
public static String buildLink(@Nullable String type, @Nullable String id) {
if (TextUtils.isEmpty(type) || TextUtils.isEmpty(id) || !isSupportedType(type)) {
return null;
}
return buildUri(Objects.requireNonNull(type), Objects.requireNonNull(id)).toString();
}
@Nullable
public static AssetLink buildAssetLink(@Nullable String type, @Nullable String id) {
String link = buildLink(type, id);
return parseLinkString(link);
}
@Nullable
public static AssetLink parseLinkString(@Nullable String link) {
if (TextUtils.isEmpty(link)) {
return null;
}
return parse(Uri.parse(link));
}
public static void copyToClipboard(@NonNull Context context, @NonNull AssetLink assetLink) {
ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboardManager == null) {
return;
}
ClipData clipData = ClipData.newPlainText(context.getString(R.string.asset_link_clipboard_label), assetLink.uri.toString());
clipboardManager.setPrimaryClip(clipData);
}
@StringRes
public static int getLabelRes(@NonNull String type) {
switch (type) {
case TYPE_SONG:
return R.string.asset_link_label_song;
case TYPE_ALBUM:
return R.string.asset_link_label_album;
case TYPE_ARTIST:
return R.string.asset_link_label_artist;
case TYPE_PLAYLIST:
return R.string.asset_link_label_playlist;
case TYPE_GENRE:
return R.string.asset_link_label_genre;
case TYPE_YEAR:
return R.string.asset_link_label_year;
default:
return R.string.asset_link_label_unknown;
}
}
public static void applyLinkAppearance(@NonNull View view) {
if (view instanceof TextView) {
TextView textView = (TextView) view;
if (textView.getTag(R.id.tag_link_original_color) == null) {
textView.setTag(R.id.tag_link_original_color, textView.getCurrentTextColor());
}
int accent = MaterialColors.getColor(view, com.google.android.material.R.attr.colorPrimary,
ContextCompat.getColor(view.getContext(), android.R.color.holo_blue_light));
textView.setTextColor(accent);
}
}
public static void clearLinkAppearance(@NonNull View view) {
if (view instanceof TextView) {
TextView textView = (TextView) view;
Object original = textView.getTag(R.id.tag_link_original_color);
if (original instanceof Integer) {
textView.setTextColor((Integer) original);
} else {
int defaultColor = MaterialColors.getColor(view, com.google.android.material.R.attr.colorOnSurface,
ContextCompat.getColor(view.getContext(), android.R.color.primary_text_light));
textView.setTextColor(defaultColor);
}
}
}
public static final class AssetLink {
public final String type;
public final String id;
public final Uri uri;
AssetLink(@NonNull String type, @NonNull String id, @NonNull Uri uri) {
this.type = type;
this.id = id;
this.uri = uri;
}
}
}

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

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;
@@ -71,6 +74,12 @@ public class MappingUtil {
bundle.putInt("originalWidth", media.getOriginalWidth() != null ? media.getOriginalWidth() : 0);
bundle.putInt("originalHeight", media.getOriginalHeight() != null ? media.getOriginalHeight() : 0);
bundle.putString("uri", uri.toString());
bundle.putString("assetLinkSong", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, media.getId()));
bundle.putString("assetLinkAlbum", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, media.getAlbumId()));
bundle.putString("assetLinkArtist", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, media.getArtistId()));
bundle.putString("assetLinkGenre", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_GENRE, media.getGenre()));
Integer year = media.getYear();
bundle.putString("assetLinkYear", year != null && year != 0 ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(year)) : null);
return new MediaItem.Builder()
.setMediaId(media.getId())
@@ -83,6 +92,13 @@ public class MappingUtil {
.setAlbumTitle(media.getAlbum())
.setArtist(media.getArtist())
.setArtworkUri(artworkUri)
.setUserRating(new HeartRating(media.getStarred() != null))
.setSupportedCommands(
ImmutableList.of(
Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON,
Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
)
)
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
@@ -110,6 +126,11 @@ public class MappingUtil {
}
public static MediaItem mapDownload(Child media) {
Bundle bundle = new Bundle();
bundle.putInt("samplingRate", media.getSamplingRate() != null ? media.getSamplingRate() : 0);
bundle.putInt("bitDepth", media.getBitDepth() != null ? media.getBitDepth() : 0);
return new MediaItem.Builder()
.setMediaId(media.getId())
.setMediaMetadata(
@@ -120,12 +141,14 @@ public class MappingUtil {
.setReleaseYear(media.getYear() != null ? media.getYear() : 0)
.setAlbumTitle(media.getAlbum())
.setArtist(media.getArtist())
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
.build()
)
.setRequestMetadata(
new MediaItem.RequestMetadata.Builder()
.setExtras(bundle)
.setMediaUri(Preferences.preferTranscodedDownload() ? MusicUtil.getTranscodedDownloadUri(media.getId()) : MusicUtil.getDownloadUri(media.getId()))
.build()
)
@@ -217,12 +240,20 @@ public class MappingUtil {
}
private static Uri getUri(Child media) {
if (Preferences.getDownloadDirectoryUri() != null) {
Uri local = ExternalAudioReader.getUri(media);
return local != null ? local : MusicUtil.getStreamUri(media.getId());
}
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(media.getId())
? getDownloadUri(media.getId())
: MusicUtil.getStreamUri(media.getId());
}
private static Uri getUri(PodcastEpisode podcastEpisode) {
if (Preferences.getDownloadDirectoryUri() != null) {
Uri local = ExternalAudioReader.getUri(podcastEpisode);
return local != null ? local : MusicUtil.getStreamUri(podcastEpisode.getStreamId());
}
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(podcastEpisode.getStreamId())
? getDownloadUri(podcastEpisode.getStreamId())
: MusicUtil.getStreamUri(podcastEpisode.getStreamId());
@@ -232,4 +263,11 @@ public class MappingUtil {
Download download = new DownloadRepository().getDownload(id);
return download != null && !download.getDownloadUri().isEmpty() ? Uri.parse(download.getDownloadUri()) : MusicUtil.getDownloadUri(id);
}
public static void observeExternalAudioRefresh(LifecycleOwner owner, Runnable onRefresh) {
if (owner == null || onRefresh == null) {
return;
}
ExternalAudioReader.getRefreshEvents().observe(owner, event -> onRefresh.run());
}
}

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,17 @@ 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"
private const val ALBUM_SORT_ORDER = "album_sort_order"
private const val DEFAULT_ALBUM_SORT_ORDER = Constants.ALBUM_ORDER_BY_NAME
@JvmStatic
fun getServer(): String? {
@@ -159,6 +169,24 @@ object Preferences {
App.getInstance().preferences.edit().putString(OPEN_SUBSONIC_EXTENSIONS, Gson().toJson(extension)).apply()
}
@JvmStatic
fun isAutoDownloadLyricsEnabled(): Boolean {
val preferences = App.getInstance().preferences
if (preferences.contains(AUTO_DOWNLOAD_LYRICS)) {
return preferences.getBoolean(AUTO_DOWNLOAD_LYRICS, false)
}
return false
}
@JvmStatic
fun setAutoDownloadLyricsEnabled(isEnabled: Boolean) {
App.getInstance().preferences.edit()
.putBoolean(AUTO_DOWNLOAD_LYRICS, isEnabled)
.apply()
}
@JvmStatic
fun getLocalAddress(): String? {
return App.getInstance().preferences.getString(LOCAL_ADDRESS, null)
@@ -300,6 +328,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 +364,16 @@ object Preferences {
).apply()
}
@JvmStatic
fun showShuffleInsteadOfHeart(): Boolean {
return App.getInstance().preferences.getBoolean(MINI_SHUFFLE_BUTTON_VISIBILITY, false)
}
@JvmStatic
fun setShuffleInsteadOfHeart(enabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(MINI_SHUFFLE_BUTTON_VISIBILITY, enabled).apply()
}
@JvmStatic
fun showServerUnreachableDialog(): Boolean {
return App.getInstance().preferences.getLong(
@@ -405,6 +467,20 @@ object Preferences {
).apply()
}
@JvmStatic
fun getDownloadDirectoryUri(): String? {
return App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null)
}
@JvmStatic
fun setDownloadDirectoryUri(uri: String?) {
val current = App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null)
if (current != uri) {
ExternalDownloadMetadataStore.clear()
}
App.getInstance().preferences.edit().putString(DOWNLOAD_DIRECTORY_URI, uri).apply()
}
@JvmStatic
fun getDefaultDownloadViewType(): String {
return App.getInstance().preferences.getString(
@@ -486,6 +562,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 +600,54 @@ object Preferences {
LAST_INSTANT_MIX, 0
) + 5000 < System.currentTimeMillis()
}
@JvmStatic
fun setAllowPlaylistDuplicates(allowDuplicates: Boolean) {
return App.getInstance().preferences.edit().putString(
ALLOW_PLAYLIST_DUPLICATES,
allowDuplicates.toString()
).apply()
}
@JvmStatic
fun allowPlaylistDuplicates(): Boolean {
return App.getInstance().preferences.getBoolean(ALLOW_PLAYLIST_DUPLICATES, false)
}
@JvmStatic
fun setEqualizerEnabled(enabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(EQUALIZER_ENABLED, enabled).apply()
}
@JvmStatic
fun isEqualizerEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(EQUALIZER_ENABLED, false)
}
@JvmStatic
fun setEqualizerBandLevels(bandLevels: ShortArray) {
val asString = bandLevels.joinToString(",")
App.getInstance().preferences.edit().putString(EQUALIZER_BAND_LEVELS, asString).apply()
}
@JvmStatic
fun getEqualizerBandLevels(bandCount: Short): ShortArray {
val str = App.getInstance().preferences.getString(EQUALIZER_BAND_LEVELS, null)
if (str.isNullOrBlank()) {
return ShortArray(bandCount.toInt())
}
val parts = str.split(",")
if (parts.size < bandCount) return ShortArray(bandCount.toInt())
return ShortArray(bandCount.toInt()) { i -> parts[i].toShortOrNull() ?: 0 }
}
@JvmStatic
fun getAlbumSortOrder(): String {
return App.getInstance().preferences.getString(ALBUM_SORT_ORDER, DEFAULT_ALBUM_SORT_ORDER) ?: DEFAULT_ALBUM_SORT_ORDER
}
@JvmStatic
fun setAlbumSortOrder(sortOrder: String) {
App.getInstance().preferences.edit().putString(ALBUM_SORT_ORDER, sortOrder).apply()
}
}

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);

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