286 Commits

Author SHA1 Message Date
eddyizm
eaac728a26 chore: bump version and change logs 2026-02-02 20:25:40 -08:00
skajmer
65d2f8e33f chore(i18n): Update Polish translation (#402)
* Add #338

* Add #3700 (strings.xml)

* Add #370 (arrays.xml)

* Add #386

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

* fix: wrong number of arguments

* feat: make dialog text localized

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

---------

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

* only update lyrics if needed

improves performance and allows user to scroll synced lyrics

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

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

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

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

* Add #3700 (strings.xml)

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

* Add French strings for instant mix generation messages

* Add French string for music download directory

* Add neutral button string for download storage dialog

* Add French strings for download refresh features

* Add French translations for heart controls and loading

* Update French strings for starred albums and artists

* Add album count string to French resources

* Add French translations for player lyrics features

* Update French strings for pluralization and playlist

* Fix French translation for podcast info title

* Add and update French radio station strings

* Add settings for playlist duplicates in French strings

* Add download folder settings in French strings

* Add download folder settings and update equalizer summary

* Add support discussion and update strings in French

* Update French strings for UI elements

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

* Update French strings for settings and lyrics

* Update French strings for offline sync settings

* Add playlist string to French resources

* Add French translations for asset links

* Revise French subtitles for starred artists and albums

Updated subtitles for starred artists and albums in French localization.

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

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

* fix: added Country code

---------

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

* Address broken continuous play 

* wip: filling queue, getting dupes

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

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

View File

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

View File

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

View File

@@ -1,6 +1,196 @@
# Changelog
## [4.0.2](https://github.com/eddyizm/tempo/releases/tag/v4.0.2) (2025-10-26)
## Pending release
## What's Changed
## [4.9.8](https://github.com/eddyizm/tempo/releases/tag/v4.9.8) (2026-02-02)
* fix: missing Replay Gain metadata from .m4a files by @pgrit in https://github.com/eddyizm/tempus/pull/396
* fix: Improve Synced Lyrics by @pgrit in https://github.com/eddyizm/tempus/pull/384
* fix: Add selector for playlist visibility by @tvillega in https://github.com/eddyizm/tempus/pull/394
* chore(i18n): set links as untranslatable by @tvillega in https://github.com/eddyizm/tempus/pull/400
## New Contributors
* @tvillega made their first contribution in https://github.com/eddyizm/tempus/pull/394
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.5...v4.5.8
## What's Changed
## [4.9.5](https://github.com/eddyizm/tempo/releases/tag/v4.9.5) (2026-01-26)
* fix: Avoid crash when server has no songs by @jaime-grj in https://github.com/eddyizm/tempus/pull/389
* fix: updated dialog import to address crashing on android 15 by @eddyizm in https://github.com/eddyizm/tempus/pull/392
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.3...v4.9.5
## What's Changed
## [4.9.3](https://github.com/eddyizm/tempo/releases/tag/v4.9.3) (2026-01-25)
* fix: Proper raw stream detection by @jaime-grj in https://github.com/eddyizm/tempus/pull/382
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/381
* feat: add configurable timeout by @eddyizm in https://github.com/eddyizm/tempus/pull/386
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.9.1...v4.9.3
## What's Changed
## [4.9.1](https://github.com/eddyizm/tempo/releases/tag/v4.9.1) (2026-01-24)
* chore: i18n: Add Romanian translation (including locale_config this time!) by @DevMatei in https://github.com/eddyizm/tempus/pull/357
* French localization update by @benoit-smith in https://github.com/eddyizm/tempus/pull/356
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/364
* docs: updated readme and added known issues for airsonic work around by @eddyizm in https://github.com/eddyizm/tempus/pull/366
* fix: toast for made for you click indication by @eddyizm in https://github.com/eddyizm/tempus/pull/365
* fix: sort playlist view by @eddyizm in https://github.com/eddyizm/tempus/pull/368
* feat: sort preference for playlists by @eddyizm in https://github.com/eddyizm/tempus/pull/370
* fix: use existing future when adding tracks, dialed random album tracks off in instant mix by @eddyizm in https://github.com/eddyizm/tempus/pull/373
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/374
* fix: Check for OpenSubsonic extensions also with password authentication by @pgrit in https://github.com/eddyizm/tempus/pull/375
* feat: Implement duration and seeking for transcodes by @drakeerv in https://github.com/eddyizm/tempus/pull/358
* feat: Playback speed controls for music by @pgrit in https://github.com/eddyizm/tempus/pull/376
## New Contributors
* @pgrit made their first contribution in https://github.com/eddyizm/tempus/pull/375
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.6.4...v4.9.1
## What's Changed
## [4.6.4](https://github.com/eddyizm/tempo/releases/tag/v4.6.4) (2026-01-13)
* fix: instant mix random songs and broken continuous play by @eddyizm in https://github.com/eddyizm/tempus/pull/354
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.6.3...v4.6.4
## What's Changed
## [4.6.3](https://github.com/eddyizm/tempo/releases/tag/v4.6.3) (2026-01-10)
* fix: give user feedback when trying to add podcast/radio on unsupport… by @eddyizm in https://github.com/eddyizm/tempus/pull/328
* docs: Clarify Android Auto enablement by @Forage in https://github.com/eddyizm/tempus/pull/336
* fix: instant mix gets a big refactor, with cascading fallbacks to produce a larger queue by @eddyizm in https://github.com/eddyizm/tempus/pull/330
* chore(i18n): add missing keys, update Chinese translation and alphabetize by @hongwei1203 in https://github.com/eddyizm/tempus/pull/332
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/339
* feat: Ability to toggle visibility of artist biography by @kmarius in https://github.com/eddyizm/tempus/pull/338
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.6.0...v4.6.3
## [4.6.0](https://github.com/eddyizm/tempo/releases/tag/v4.6.0) (2025-12-22)
## What's Changed
* chore: Update description_empty_title in English and Polish by @tyren234 in https://github.com/eddyizm/tempus/pull/307
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/310
* fix: checks preference and writes files externally, updates the ui by @eddyizm in https://github.com/eddyizm/tempus/pull/312
* chore: Update description_empty_title in Italian by @pochopsp in https://github.com/eddyizm/tempus/pull/314
* chore: Update description_empty_title in French and Spanish by @pochopsp in https://github.com/eddyizm/tempus/pull/315
* feat: added regular playlist to home view by @eddyizm in https://github.com/eddyizm/tempus/pull/322
## New Contributors
* @tyren234 made their first contribution in https://github.com/eddyizm/tempus/pull/307
* @pochopsp made their first contribution in https://github.com/eddyizm/tempus/pull/314
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.5.0...v4.6.0
## [4.5.0](https://github.com/eddyizm/tempo/releases/tag/v4.5.0) (2025-12-12)
## What's Changed
* fix: updates starred syncing downloads to user defined directory by @eddyizm in https://github.com/eddyizm/tempus/pull/298
* fix: handle empty albums and null mappings by @eddyizm in https://github.com/eddyizm/tempus/pull/301
* feat: integrate sort recent searches chronologically by @J4mm3ris in https://github.com/eddyizm/tempus/pull/300
* feat: add heart to artist/album pages, fixed artist cover art failing by @eddyizm in https://github.com/eddyizm/tempus/pull/303
## New Contributors
* @J4mm3ris made their first contribution in https://github.com/eddyizm/tempus/pull/300
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.4.0...v4.5.0
## [4.4.0](https://github.com/eddyizm/tempo/releases/tag/v4.4.0) (2025-11-29)
## What's Changed
* chore: bringing in media service refactor previously reverted after more testing by @eddyizm in https://github.com/eddyizm/tempus/pull/286
* fix: refactor start queue to put the db writing in the background to address instant mix bug by @eddyizm in https://github.com/eddyizm/tempus/pull/287
* Feat: playerqueue fab allows playqueue actions -> saving to playlist, download all, load queue, shuffle, clean queue by @eddyizm in https://github.com/eddyizm/tempus/pull/288
* chore(i18n): Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/291
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.3.0...v4.4.0
## [4.3.0](https://github.com/eddyizm/tempo/releases/tag/v4.3.0) (2025-11-23)
## What's Changed
* chore: Add Obtainium badge to README by @mikaeldui in https://github.com/eddyizm/tempus/pull/280
* fix: Revert "refactor MediaService" by @eddyizm in https://github.com/eddyizm/tempus/pull/282
* feat: add play functionality to library folder/index items by @antebudimir in https://github.com/eddyizm/tempus/pull/276
* fix: start queue blocking UI by @eddyizm in https://github.com/eddyizm/tempus/pull/283
## New Contributors
* @mikaeldui made their first contribution in https://github.com/eddyizm/tempus/pull/280
* @antebudimir made their first contribution in https://github.com/eddyizm/tempus/pull/276
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.2.6...v4.3.0
## [4.2.6](https://github.com/eddyizm/tempo/releases/tag/v4.2.6) (2025-11-22)
## What's Changed
* fix: Fix player queue soft-lock by @shrapnelnet in https://github.com/eddyizm/tempus/pull/266
* chore: Add Catalan i18n by @marcriera in https://github.com/eddyizm/tempus/pull/268
* chore: Refactor MediaService by @pca006132 in https://github.com/eddyizm/tempus/pull/267
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/272
* chore(i18n): Update Italian translation by @66Bunz in https://github.com/eddyizm/tempus/pull/278
## New Contributors
* @marcriera made their first contribution in https://github.com/eddyizm/tempus/pull/268
* @66Bunz made their first contribution in https://github.com/eddyizm/tempus/pull/278
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.2.4...v4.2.6
## [4.2.4](https://github.com/eddyizm/tempo/releases/tag/v4.2.4) (2025-11-15)
## What's Changed
* chore: Update russian strings.xml by @Sevinfolds in https://github.com/eddyizm/tempus/pull/249
* fix: disallow duplicate songs in queue by @eddyizm in https://github.com/eddyizm/tempus/pull/252
* fix:github release check by @eddyizm in https://github.com/eddyizm/tempus/pull/253
* fix: Fixed crash when viewing share by @drakeerv in https://github.com/eddyizm/tempus/pull/255
* chore: Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/257
* fix: add podcast/radio channel visible when empty podcasts/radio by @eddyizm in https://github.com/eddyizm/tempus/pull/260
## New Contributors
* @Sevinfolds made their first contribution in https://github.com/eddyizm/tempus/pull/249
* @drakeerv made their first contribution in https://github.com/eddyizm/tempus/pull/255
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.2.0...v4.2.4
## [4.2.0](https://github.com/eddyizm/tempo/releases/tag/v4.2.0) (2025-11-09)
## What's Changed
* fix: Equalizer fix in main build variant by @jaime-grj in https://github.com/eddyizm/tempus/pull/239
* fix: Images not filling holder by @eddyizm in https://github.com/eddyizm/tempus/pull/244
* feat: Make artist and album clickable by @eddyizm in https://github.com/eddyizm/tempus/pull/243
* feat: implement scroll to currently playing feature by @shrapnelnet in https://github.com/eddyizm/tempus/pull/247
* fix: shuffling genres only queuing 25 songs by @shrapnelnet in https://github.com/eddyizm/tempus/pull/246
## New Contributors
* @shrapnelnet made their first contribution in https://github.com/eddyizm/tempus/pull/247
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.1.3...v4.2.0
## [4.1.3](https://github.com/eddyizm/tempo/releases/tag/v4.1.3) (2025-11-06)
## What's Changed
* [fix: equalizer missing referenced value](https://github.com/eddyizm/tempus/commit/923cfd5bc97ed7db28c90348e3619d0a784fc434)
* Fix: Album track list bug by @eddyizm in https://github.com/eddyizm/tempus/pull/237
* fix: Add listener to enable equalizer when audioSessionId changes by @jaime-grj in https://github.com/eddyizm/tempus/pull/235
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.1.0...v4.1.3
## [4.1.0](https://github.com/eddyizm/tempo/releases/tag/v4.1.0) (2025-11-05)
## What's Changed
* chore(i18n): Update Spanish (es-ES) translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/205
* shuffle for artists without using `getTopSongs` by @pca006132 in https://github.com/eddyizm/tempus/pull/207
* Update USAGE.md with instant mix details by @zc-devs in https://github.com/eddyizm/tempus/pull/220
* feat: sort artists by album count by @pca006132 in https://github.com/eddyizm/tempus/pull/206
* Fix downloaded tab performance by @pca006132 in https://github.com/eddyizm/tempus/pull/210
* fix: remove NestedScrollViews for fragment_album_page by @pca006132 in https://github.com/eddyizm/tempus/pull/216
* fix: playlist page should not snap by @pca006132 in https://github.com/eddyizm/tempus/pull/218
* fix: do not override getItemViewType and getItemId by @pca006132 in https://github.com/eddyizm/tempus/pull/221
* chore: update media3 dependencies by @pca006132 in https://github.com/eddyizm/tempus/pull/217
* fix: update MediaItems after network change by @pca006132 in https://github.com/eddyizm/tempus/pull/222
* fix: skip mapping downloaded item by @pca006132 in https://github.com/eddyizm/tempus/pull/228
## New Contributors
* @pca006132 made their first contribution in https://github.com/eddyizm/tempus/pull/207
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.0.7...v4.1.0
## [4.0.7](https://github.com/eddyizm/tempo/releases/tag/v4.0.7) (2025-10-28)
## What's Changed
* chore: updated tempo references to tempus including github check by @eddyizm in https://github.com/eddyizm/tempus/pull/197
* fix: Crash on share no expiration date or field returned from api by @eddyizm in https://github.com/eddyizm/tempus/pull/199
**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.0.6...v4.0.7
## [4.0.6](https://github.com/eddyizm/tempo/releases/tag/v4.0.6) (2025-10-26)
## Attention
This release will not update previous installs as it is considered a new app, no longer `Tempo`, new icon, new app id, and new app name. Hoping it will not be a huge inconvenience but was necessary in order to publish to app stores like IzzyDroid and FDroid.

View File

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

View File

@@ -12,7 +12,7 @@
- [Playlist Management](#playlist-management)
- [Android Auto](#android-auto)
- [Settings](#settings)
- [Troubleshooting](#troubleshooting)
- [Known Issues](#known-issues)
## Prerequisites
@@ -27,7 +27,9 @@ This app works with any service that implements the Subsonic API, including:
- [LMS - Lightweight Music Server](https://github.com/epoupon/lms) - *personal fave and my backend*
- [Navidrome](https://www.navidrome.org/)
- [Gonic](https://github.com/sentriz/gonic)
- [Ampache](https://github.com/ampache/ampache)
- [NextCloud Music](https://apps.nextcloud.com/apps/music)
- [Airsonic Advanced](https://github.com/kagemomiji/airsonic-advanced)
@@ -66,6 +68,21 @@ However, if you want to limit or change libraries you could use a workaround, if
You can create multiple users , one for each library, and save each of them in Tempus app.
### Folder or index playback
If your Subsonic-compatible server exposes the folder tree **or** provides an artist index (for example Gonic, Navidrome, or any backend with folder browsing enabled), Tempus lets you play an entire folder from anywhere in the library hierarchy:
<p align="left">
<img src="mockup/usage/music_folders_root.png" width=317 style="margin-right:16px;">
<img src="mockup/usage/music_folders_playback.png" width=317>
</p>
- The **Library ▸ Music folders** screen shows each top-level folder with a play icon only after you drill into it. The root entry remains a simple navigator.
- When viewing **inner folders** **or artist index entries**, tap the new play button to immediately enqueue every audio track inside that folder/index and all nested subfolders.
- Video files are excluded automatically, so only playable audio ends up in the queue.
No extra config is needed—Tempus adjusts based on the connected backend.
### Now Playing Screen
On the main player control screen, tapping on the artwork will reveal a small collection of 4 buttons/icons.
@@ -78,9 +95,23 @@ On the main player control screen, tapping on the artwork will reveal a small co
1. Downloads the track (there is a notification if the android screen but not a pop toast currently )
2. Adds track to playlist - pops up playlist dialog.
3. Adds tracks to the queue via instant mix function
* TBD: what is the _instant mix function_?
* Uses [getSimilarSongs](https://opensubsonic.netlify.app/docs/endpoints/getsimilarsongs/) of OpenSubsonic API.
Which tracks to be mixed depends on the server implementation. For example, Navidrome gets 15 similar artists from LastFM, then 20 top songs from each.
4. Saves play queue (if the feature is enabled in the settings)
* if the setting is not enabled, it toggles a view of the lyrics if available (slides to the right)
### Podcasts
If your server supports it - add a podcast rss feed
<p align="left">
<img src="mockup/usage/add_podcast_feed.png" width=317>
</p>
### Radio Stations
If your server supports it - add a internet radio station feed
<p align="left">
<img src="mockup/usage/add_radio_station.png" width=326>
</p>
## Navigation
@@ -128,7 +159,23 @@ On the main player control screen, tapping on the artwork will reveal a small co
## 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)
To allow the Tempus app on your car's head unit, "Unknown sources" needs to be enabled in the Android Auto "Developer settings". This is because Tempus isn't installed through Play Store. Note that the Android Auto developer settings are different from the global Android "Developer options".
1. Switch to developer mode in the Android Auto settings by tapping ten times on the "Version" item at the bottom, followed by giving your permission.
<p align="left">
<img width="270" height="600" alt="1a" src="https://github.com/user-attachments/assets/f09f6999-9761-4b05-8ec7-bf221a15dda3" />
<img width="270" height="600" alt="1b" src="https://github.com/user-attachments/assets/0795e508-ba01-41c5-96a7-7c03b0156591" />
<img width="270" height="600" alt="1c" src="https://github.com/user-attachments/assets/51c15f67-fddb-452e-b5d3-5092edeab390" />
</p>
2. Go to the "Developer settings" by the menu at the top right.
<p align="left">
<img width="270" height="600" alt="2" src="https://github.com/user-attachments/assets/1ecd1f3e-026d-4d25-87f2-be7f12efbac6" />
</p>
3. Scroll down to the bottom and check "Unknown sources".
<p align="left">
<img width="270" height="600" alt="3" src="https://github.com/user-attachments/assets/37db88e9-1b76-417f-9c47-da9f3a750fff" />
</p>
### Server Settings
@@ -145,15 +192,12 @@ On the main player control screen, tapping on the artwork will reveal a small co
### Appearance
**TODO**
## Troubleshooting
## Known Issues
### Connection Issues
### Airsonic Distorted Playback
**TODO**
### Common Issues
**TODO**
First reported in issue [#226](https://github.com/eddyizm/tempus/issues/226)
The work around is to disable the cache in the settings, (set to 0), and if needed, cleaning the (Android) cache fixes the problem.
### Support
For additional help:
@@ -163,4 +207,4 @@ For additional help:
---
*Note: This app requires a pre-existing Subsonic-compatible server with music content.*
*Note: This app requires a pre-existing Subsonic-compatible server with music content.*

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -30,7 +30,7 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
@UnstableApi
@Database(
version = 12,
version = 13,
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)}
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,5 @@
package com.cappielloantonio.tempo.repository;
import static android.provider.Settings.System.getString;
import android.provider.Settings;
import android.widget.Toast;
import androidx.annotation.NonNull;
@@ -107,13 +104,13 @@ public class PlaylistRepository {
return playlistLiveData;
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) {
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId, Boolean playlistVisibilityIsPublic) {
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)
.updatePlaylist(playlistId, null, playlistVisibilityIsPublic, songsId, null)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -354,7 +354,7 @@ public class MainActivity extends BaseActivity {
// TODO Enter all settings to be reset
Preferences.setOpenSubsonic(false);
Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_100);
Preferences.setPlaybackSpeed(1.0f);
Preferences.setSkipSilenceMode(false);
Preferences.setDataSavingMode(false);
Preferences.setStarredSyncEnabled(false);
@@ -384,7 +384,7 @@ public class MainActivity extends BaseActivity {
}
private void pingServer() {
if (Preferences.getToken() == null) return;
if (Preferences.getToken() == null && Preferences.getPassword() == null) return;
if (Preferences.isInUseServerAddressLocal()) {
mainViewModel.ping().observe(this, subsonicResponse -> {
@@ -428,7 +428,7 @@ public class MainActivity extends BaseActivity {
}
private void getOpenSubsonicExtensions() {
if (Preferences.getToken() != null) {
if (Preferences.getToken() != null || Preferences.getPassword() != null) {
mainViewModel.getOpenSubsonicExtensions().observe(this, openSubsonicExtensions -> {
if (openSubsonicExtensions != null) {
Preferences.setOpenSubsonicExtensions(openSubsonicExtensions);
@@ -438,7 +438,7 @@ public class MainActivity extends BaseActivity {
}
private void checkTempoUpdate() {
if (BuildConfig.FLAVOR.equals("tempo") && Preferences.showTempoUpdateDialog()) {
if (BuildConfig.FLAVOR.equals("tempus") && Preferences.isGithubUpdateEnabled() && Preferences.showTempusUpdateDialog()) {
mainViewModel.checkTempoUpdate().observe(this, latestRelease -> {
if (latestRelease != null && UpdateUtil.showUpdateDialog(latestRelease)) {
GithubTempoUpdateDialog dialog = new GithubTempoUpdateDialog(latestRelease);

View File

@@ -151,6 +151,9 @@ public class ArtistCatalogueAdapter extends RecyclerView.Adapter<ArtistCatalogue
case Constants.ARTIST_ORDER_BY_RANDOM:
Collections.shuffle(artists);
break;
case Constants.ARTIST_ORDER_BY_ALBUM_COUNT:
artists.sort(Comparator.comparing(ArtistID3::getAlbumCount).reversed());
break;
}
notifyDataSetChanged();

View File

@@ -73,6 +73,11 @@ public class DiscoverSongAdapter extends RecyclerView.Adapter<DiscoverSongAdapte
this.item = item;
itemView.setOnClickListener(v -> onClick());
itemView.setOnLongClickListener(v -> {
onLongClick();
return true;
});
}
public void onClick() {
@@ -82,6 +87,13 @@ public class DiscoverSongAdapter extends RecyclerView.Adapter<DiscoverSongAdapte
click.onMediaClick(bundle);
}
private boolean onLongClick() {
Bundle bundle = new Bundle();
bundle.putParcelable(Constants.TRACK_OBJECT, songs.get(getBindingAdapterPosition()));
click.onMediaLongClick(bundle);
return true;
}
}
private void startAnimation(ViewHolder holder) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
@@ -20,6 +21,7 @@ import com.cappielloantonio.tempo.viewmodel.PlaylistChooserViewModel;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
public class PlaylistChooserDialog extends DialogFragment implements ClickCallback {
private DialogPlaylistChooserBinding bind;
@@ -35,9 +37,21 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba
playlistChooserViewModel = new ViewModelProvider(requireActivity()).get(PlaylistChooserViewModel.class);
String[] playlistVisibilityChoice = {
getString(R.string.playlist_chooser_dialog_visibility_public),
getString(R.string.playlist_chooser_dialog_visibility_private)
};
return new MaterialAlertDialogBuilder(getActivity())
.setView(bind.getRoot())
.setTitle(R.string.playlist_chooser_dialog_title)
.setSingleChoiceItems(
playlistVisibilityChoice,
0,
(dialog, which) -> {
boolean isPublic = (which == 0);
playlistChooserViewModel.setIsPlaylistPublic(isPublic);
})
.setNeutralButton(R.string.playlist_chooser_dialog_neutral_button, (dialog, id) -> { })
.setNegativeButton(R.string.playlist_chooser_dialog_negative_button, (dialog, id) -> dialog.cancel())
.create();

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,7 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.ArtistCatalogueAdapter;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.ArtistCatalogueViewModel;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
@@ -114,7 +115,10 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
artistAdapter = new ArtistCatalogueAdapter(this);
artistAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY);
bind.artistCatalogueRecyclerView.setAdapter(artistAdapter);
artistCatalogueViewModel.getArtistList().observe(getViewLifecycleOwner(), artistList -> artistAdapter.setItems(artistList));
artistCatalogueViewModel.getArtistList().observe(getViewLifecycleOwner(), artistList -> {
artistAdapter.setItems(artistList);
artistAdapter.sort(Preferences.getArtistSortOrder());
});
bind.artistCatalogueRecyclerView.setOnTouchListener((v, event) -> {
hideKeyboard(v);
@@ -192,6 +196,9 @@ public class ArtistCatalogueFragment extends Fragment implements ClickCallback {
} else if (menuItem.getItemId() == R.id.menu_artist_sort_random) {
artistAdapter.sort(Constants.ARTIST_ORDER_BY_RANDOM);
return true;
} else if (menuItem.getItemId() == R.id.menu_artist_sort_album_count) {
artistAdapter.sort(Constants.ARTIST_ORDER_BY_ALBUM_COUNT);
return true;
}
return false;

View File

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

View File

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

View File

@@ -117,14 +117,12 @@ public class DownloadFragment extends Fragment implements ClickCallback {
if (songs.isEmpty()) {
if (bind != null) {
bind.emptyDownloadLayout.setVisibility(View.VISIBLE);
bind.fragmentDownloadNestedScrollView.setVisibility(View.GONE);
bind.downloadDownloadedSector.setVisibility(View.GONE);
bind.downloadedGroupByImageView.setVisibility(View.GONE);
}
} else {
if (bind != null) {
bind.emptyDownloadLayout.setVisibility(View.GONE);
bind.fragmentDownloadNestedScrollView.setVisibility(View.VISIBLE);
bind.downloadDownloadedSector.setVisibility(View.VISIBLE);
bind.downloadedGroupByImageView.setVisibility(View.VISIBLE);

View File

@@ -3,7 +3,9 @@ package com.cappielloantonio.tempo.ui.fragment
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.BroadcastReceiver
import android.os.Bundle
import android.os.IBinder
import android.view.Gravity
@@ -12,10 +14,12 @@ import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.annotation.OptIn
import androidx.core.content.ContextCompat
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.BaseMediaService
import com.cappielloantonio.tempo.service.MediaService
import com.cappielloantonio.tempo.util.Preferences
@@ -28,10 +32,21 @@ class EqualizerFragment : Fragment() {
private lateinit var safeSpace: Space
private val bandSeekBars = mutableListOf<SeekBar>()
private var receiverRegistered = false
private val equalizerUpdatedReceiver = object : BroadcastReceiver() {
@OptIn(UnstableApi::class)
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == BaseMediaService.ACTION_EQUALIZER_UPDATED) {
initUI()
restoreEqualizerPreferences()
}
}
}
private val connection = object : ServiceConnection {
@OptIn(UnstableApi::class)
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as MediaService.LocalBinder
val binder = service as BaseMediaService.LocalBinder
equalizerManager = binder.getEqualizerManager()
initUI()
restoreEqualizerPreferences()
@@ -46,15 +61,32 @@ class EqualizerFragment : Fragment() {
override fun onStart() {
super.onStart()
Intent(requireContext(), MediaService::class.java).also { intent ->
intent.action = MediaService.ACTION_BIND_EQUALIZER
intent.action = BaseMediaService.ACTION_BIND_EQUALIZER
requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
if (!receiverRegistered) {
ContextCompat.registerReceiver(
requireContext(),
equalizerUpdatedReceiver,
IntentFilter(BaseMediaService.ACTION_EQUALIZER_UPDATED),
ContextCompat.RECEIVER_NOT_EXPORTED
)
receiverRegistered = true
}
}
override fun onStop() {
super.onStop()
requireActivity().unbindService(connection)
equalizerManager = null
if (receiverRegistered) {
try {
requireContext().unregisterReceiver(equalizerUpdatedReceiver)
} catch (_: Exception) {
// ignore if not registered
}
receiverRegistered = false
}
}
override fun onCreateView(
@@ -234,4 +266,4 @@ class EqualizerFragment : Fragment() {
}
private fun Int.dpToPx(context: Context): Int =
(this * context.resources.displayMetrics.density).toInt()
(this * context.resources.displayMetrics.density).toInt()

View File

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

View File

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

View File

@@ -11,7 +11,11 @@ import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.media3.session.SessionToken;
import androidx.navigation.Navigation;
import android.content.ComponentName;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
@@ -31,6 +35,8 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.LibraryViewModel;
import com.google.android.material.appbar.MaterialToolbar;
import com.cappielloantonio.tempo.service.MediaService;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.Objects;
@@ -49,6 +55,7 @@ public class LibraryFragment extends Fragment implements ClickCallback {
private PlaylistHorizontalAdapter playlistHorizontalAdapter;
private MaterialToolbar materialToolbar;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@Nullable
@Override
@@ -79,6 +86,7 @@ public class LibraryFragment extends Fragment implements ClickCallback {
@Override
public void onStart() {
super.onStart();
initializeMediaBrowser();
activity.setBottomNavigationBarVisibility(true);
}
@@ -292,4 +300,8 @@ public class LibraryFragment extends Fragment implements ClickCallback {
public void onMusicFolderClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.indexFragment, bundle);
}
private void initializeMediaBrowser() {
mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync();
}
}

View File

@@ -413,10 +413,10 @@ public class PlayerControllerFragment extends Fragment {
bind.getRoot().setShowNextButton(true);
bind.getRoot().setShowFastForwardButton(false);
bind.getRoot().setRepeatToggleModes(RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL | RepeatModeUtil.REPEAT_TOGGLE_MODE_ONE);
bind.getRoot().findViewById(R.id.player_playback_speed_button).setVisibility(View.GONE);
bind.getRoot().findViewById(R.id.player_playback_speed_button).setVisibility(View.VISIBLE);
bind.getRoot().findViewById(R.id.player_skip_silence_toggle_button).setVisibility(View.GONE);
bind.getRoot().findViewById(R.id.button_favorite).setVisibility(View.VISIBLE);
resetPlaybackParameters(mediaBrowser);
setPlaybackParameters(mediaBrowser);
break;
}
}
@@ -524,31 +524,11 @@ public class PlayerControllerFragment extends Fragment {
playbackSpeedButton.setOnClickListener(view -> {
float currentSpeed = Preferences.getPlaybackSpeed();
if (currentSpeed == Constants.MEDIA_PLAYBACK_SPEED_080) {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_100));
playbackSpeedButton.setText(getString(R.string.player_playback_speed, Constants.MEDIA_PLAYBACK_SPEED_100));
Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_100);
} else if (currentSpeed == Constants.MEDIA_PLAYBACK_SPEED_100) {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_125));
playbackSpeedButton.setText(getString(R.string.player_playback_speed, Constants.MEDIA_PLAYBACK_SPEED_125));
Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_125);
} else if (currentSpeed == Constants.MEDIA_PLAYBACK_SPEED_125) {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_150));
playbackSpeedButton.setText(getString(R.string.player_playback_speed, Constants.MEDIA_PLAYBACK_SPEED_150));
Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_150);
} else if (currentSpeed == Constants.MEDIA_PLAYBACK_SPEED_150) {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_175));
playbackSpeedButton.setText(getString(R.string.player_playback_speed, Constants.MEDIA_PLAYBACK_SPEED_175));
Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_175);
} else if (currentSpeed == Constants.MEDIA_PLAYBACK_SPEED_175) {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_200));
playbackSpeedButton.setText(getString(R.string.player_playback_speed, Constants.MEDIA_PLAYBACK_SPEED_200));
Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_200);
} else if (currentSpeed == Constants.MEDIA_PLAYBACK_SPEED_200) {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_080));
playbackSpeedButton.setText(getString(R.string.player_playback_speed, Constants.MEDIA_PLAYBACK_SPEED_080));
Preferences.setPlaybackSpeed(Constants.MEDIA_PLAYBACK_SPEED_080);
}
currentSpeed += 0.25f;
if (currentSpeed > 2.0f) currentSpeed = 0.5f;
mediaBrowser.setPlaybackParameters(new PlaybackParameters(currentSpeed));
playbackSpeedButton.setText(getString(R.string.player_playback_speed, currentSpeed));
Preferences.setPlaybackSpeed(currentSpeed);
});
skipSilenceToggleButton.setOnClickListener(view -> {
@@ -600,7 +580,7 @@ public class PlayerControllerFragment extends Fragment {
}
private void resetPlaybackParameters(MediaBrowser mediaBrowser) {
mediaBrowser.setPlaybackParameters(new PlaybackParameters(Constants.MEDIA_PLAYBACK_SPEED_100));
mediaBrowser.setPlaybackParameters(new PlaybackParameters(1.0f));
// TODO Resettare lo skip del silenzio
}

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ import android.media.audiofx.AudioEffect;
import android.net.Uri;
import android.os.Bundle;
import android.os.IBinder;
import android.text.InputFilter;
import android.text.InputType;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -27,8 +29,10 @@ import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController;
import androidx.navigation.NavOptions;
import androidx.navigation.fragment.NavHostFragment;
import androidx.preference.EditTextPreference;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceCategory;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.SwitchPreference;
@@ -77,6 +81,13 @@ public class SettingsFragment extends PreferenceFragmentCompat {
result -> {}
);
if (!BuildConfig.FLAVOR.equals("tempus")) {
PreferenceCategory githubUpdateCategory = findPreference("settings_github_update_category_key");
if (githubUpdateCategory != null) {
getPreferenceScreen().removePreference(githubUpdateCategory);
}
}
directoryPickerLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
@@ -133,6 +144,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
setStreamingCacheSize();
setAppLanguage();
setVersion();
setNetorkPingTimeoutBase();
actionLogout();
actionScan();
@@ -253,6 +265,30 @@ public class SettingsFragment extends PreferenceFragmentCompat {
}
}
private void setNetorkPingTimeoutBase() {
EditTextPreference networkPingTimeoutBase = findPreference("network_ping_timeout_base");
if (networkPingTimeoutBase != null) {
networkPingTimeoutBase.setSummaryProvider(EditTextPreference.SimpleSummaryProvider.getInstance());
networkPingTimeoutBase.setOnBindEditTextListener(editText -> {
editText.setInputType(InputType.TYPE_CLASS_NUMBER);
editText.setFilters(new InputFilter[]{ (source, start, end, dest, dstart, dend) -> {
for (int i = start; i < end; i++) {
if (!Character.isDigit(source.charAt(i))) {
return "";
}
}
return null;
}});
});
networkPingTimeoutBase.setOnPreferenceChangeListener((preference, newValue) -> {
String input = (String) newValue;
return input != null && !input.isEmpty();
});
}
}
private void setStreamingCacheSize() {
ListPreference streamingCachePreference = findPreference("streaming_cache_size");

View File

@@ -189,7 +189,7 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
bind.songListShuffleImageView.setOnClickListener(v -> {
Collections.shuffle(songs);
MediaManager.startQueue(mediaBrowserListenableFuture, songs.subList(0, Math.min(25, songs.size())), 0);
MediaManager.startQueue(mediaBrowserListenableFuture, songs.subList(0, Math.min(500, songs.size())), 0);
activity.setBottomSheetInPeek(true);
});
}

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.subsonic.models.Share;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.dialog.ShareUpdateDialog;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.UIUtil;
@@ -24,6 +25,8 @@ import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
import com.cappielloantonio.tempo.viewmodel.ShareBottomSheetViewModel;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import java.util.List;
@UnstableApi
public class ShareBottomSheetDialog extends BottomSheetDialogFragment implements View.OnClickListener {
@@ -50,8 +53,15 @@ public class ShareBottomSheetDialog extends BottomSheetDialogFragment implements
private void init(View view) {
ImageView shareCover = view.findViewById(R.id.share_cover_image_view);
String coverArtId = null;
List<Child> entries = shareBottomSheetViewModel.getShare().getEntries();
if (entries != null && !entries.isEmpty()) {
coverArtId = entries.get(0).getCoverArtId();
}
CustomGlideRequest.Builder
.from(requireContext(), shareBottomSheetViewModel.getShare().getEntries().get(0).getCoverArtId(), CustomGlideRequest.ResourceType.Unknown)
.from(requireContext(), coverArtId, CustomGlideRequest.ResourceType.Unknown)
.build()
.into(shareCover);

View File

@@ -5,6 +5,7 @@ import android.content.ClipboardManager;
import android.content.ComponentName;
import android.content.Context;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@@ -44,8 +45,6 @@ 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;
@@ -67,7 +66,9 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
private AssetLinkUtil.AssetLink currentAlbumLink;
private AssetLinkUtil.AssetLink currentArtistLink;
private boolean isFirstBatch = true;
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
private static final String TAG = "SongBottomSheetDialog";
@Nullable
@Override
@@ -143,20 +144,34 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
TextView playRadio = view.findViewById(R.id.play_radio_text_view);
playRadio.setOnClickListener(v -> {
MediaManager.startQueue(mediaBrowserListenableFuture, song);
((MainActivity) requireActivity()).setBottomSheetInPeek(true);
MainActivity activity = (MainActivity) getActivity();
if (activity == null) return;
songBottomSheetViewModel.getInstantMix(getViewLifecycleOwner(), song).observe(getViewLifecycleOwner(), songs -> {
MusicUtil.ratingFilter(songs);
ListenableFuture<MediaBrowser> activityBrowserFuture = activity.getMediaBrowserListenableFuture();
if (activityBrowserFuture == null) {
Log.e(TAG, "MediaBrowser Future is null in MainActivity");
return;
}
if (songs == null) {
dismissBottomSheet();
return;
}
isFirstBatch = true;
Toast.makeText(requireContext(), R.string.bottom_sheet_generating_instant_mix, Toast.LENGTH_SHORT).show();
if (!songs.isEmpty()) {
MediaManager.enqueue(mediaBrowserListenableFuture, songs, true);
dismissBottomSheet();
songBottomSheetViewModel.getInstantMix(activity, song).observe(activity, media -> {
if (media == null || media.isEmpty()) return;
if (getActivity() == null) return;
MusicUtil.ratingFilter(media);
if (isFirstBatch) {
isFirstBatch = false;
MediaManager.startQueue(activityBrowserFuture, media, 0);
activity.setBottomSheetInPeek(true);
if (isAdded()) {
dismissBottomSheet();
}
} else {
MediaManager.enqueue(activityBrowserFuture, media, true);
}
});
});
@@ -327,16 +342,12 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
chip.setVisibility(View.VISIBLE);
chip.setOnClickListener(v -> {
if (assetLink != null) {
((MainActivity) requireActivity()).openAssetLink(assetLink);
}
((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();
}
AssetLinkUtil.copyToClipboard(requireContext(), assetLink);
Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, id), Toast.LENGTH_SHORT).show();
return true;
});
@@ -397,4 +408,5 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
private void refreshShares() {
homeViewModel.refreshShares(requireActivity());
}
}

View File

@@ -40,6 +40,7 @@ object Constants {
const val ARTIST_STARRED = "ARTIST_STARRED"
const val ARTIST_ORDER_BY_NAME = "ARTIST_ORDER_BY_NAME"
const val ARTIST_ORDER_BY_RANDOM = "ARTIST_ORDER_BY_RANDOM"
const val ARTIST_ORDER_BY_ALBUM_COUNT = "ARTIST_ORDER_BY_ALBUM_COUNT"
const val ARTIST_ORDER_BY_MOST_RECENTLY_STARRED = "ARTIST_ORDER_BY_MOST_RECENTLY_STARRED"
const val ARTIST_ORDER_BY_LEAST_RECENTLY_STARRED = "ARTIST_ORDER_BY_LEAST_RECENTLY_STARRED"
@@ -60,13 +61,6 @@ object Constants {
const val MEDIA_TYPE_VIDEO = "video"
const val MEDIA_TYPE_RADIO = "radio"
const val MEDIA_PLAYBACK_SPEED_080 = 0.8f
const val MEDIA_PLAYBACK_SPEED_100 = 1.0f
const val MEDIA_PLAYBACK_SPEED_125 = 1.25f
const val MEDIA_PLAYBACK_SPEED_150 = 1.50f
const val MEDIA_PLAYBACK_SPEED_175 = 1.75f
const val MEDIA_PLAYBACK_SPEED_200 = 2.0f
const val MEDIA_RECENTLY_PLAYED = "MEDIA_RECENTLY_PLAYED"
const val MEDIA_MOST_PLAYED = "MEDIA_MOST_PLAYED"
const val MEDIA_RECENTLY_ADDED = "MEDIA_RECENTLY_ADDED"
@@ -132,4 +126,7 @@ object Constants {
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"
enum class SeedType {
ARTIST, ALBUM, TRACK
}
}

View File

@@ -46,8 +46,16 @@ class DynamicMediaSourceFactory(
else -> {
val extractorsFactory: ExtractorsFactory = DefaultExtractorsFactory()
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
.createMediaSource(mediaItem)
val progressiveFactory = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
val uri = mediaItem.localConfiguration?.uri
val isTranscoding = uri?.getQueryParameter("format") != null && uri.getQueryParameter("format") != "raw"
if (isTranscoding && OpenSubsonicExtensionsUtil.isTranscodeOffsetExtensionAvailable()) {
TranscodingMediaSource(mediaItem, dataSourceFactory, progressiveFactory)
} else {
progressiveFactory.createMediaSource(mediaItem)
}
}
}
}

View File

@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.util;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
@@ -35,79 +36,124 @@ public class MappingUtil {
return mediaItems;
}
private static final String TAG = "MappingUtil";
public static MediaItem mapMediaItem(Child media) {
Uri uri = getUri(media);
Uri artworkUri = Uri.parse(CustomGlideRequest.createUrl(media.getCoverArtId(), Preferences.getImageSize()));
try {
Uri uri = getUri(media);
String coverArtId = media.getCoverArtId();
Uri artworkUri = null;
Bundle bundle = new Bundle();
bundle.putString("id", media.getId());
bundle.putString("parentId", media.getParentId());
bundle.putBoolean("isDir", media.isDir());
bundle.putString("title", media.getTitle());
bundle.putString("album", media.getAlbum());
bundle.putString("artist", media.getArtist());
bundle.putInt("track", media.getTrack() != null ? media.getTrack() : 0);
bundle.putInt("year", media.getYear() != null ? media.getYear() : 0);
bundle.putString("genre", media.getGenre());
bundle.putString("coverArtId", media.getCoverArtId());
bundle.putLong("size", media.getSize() != null ? media.getSize() : 0);
bundle.putString("contentType", media.getContentType());
bundle.putString("suffix", media.getSuffix());
bundle.putString("transcodedContentType", media.getTranscodedContentType());
bundle.putString("transcodedSuffix", media.getTranscodedSuffix());
bundle.putInt("duration", media.getDuration() != null ? media.getDuration() : 0);
bundle.putInt("bitrate", media.getBitrate() != null ? media.getBitrate() : 0);
bundle.putInt("samplingRate", media.getSamplingRate() != null ? media.getSamplingRate() : 0);
bundle.putInt("bitDepth", media.getBitDepth() != null ? media.getBitDepth() : 0);
bundle.putString("path", media.getPath());
bundle.putBoolean("isVideo", media.isVideo());
bundle.putInt("userRating", media.getUserRating() != null ? media.getUserRating() : 0);
bundle.putDouble("averageRating", media.getAverageRating() != null ? media.getAverageRating() : 0);
bundle.putLong("playCount", media.getPlayCount() != null ? media.getPlayCount() : 0);
bundle.putInt("discNumber", media.getDiscNumber() != null ? media.getDiscNumber() : 0);
bundle.putLong("created", media.getCreated() != null ? media.getCreated().getTime() : 0);
bundle.putLong("starred", media.getStarred() != null ? media.getStarred().getTime() : 0);
bundle.putString("albumId", media.getAlbumId());
bundle.putString("artistId", media.getArtistId());
bundle.putString("type", Constants.MEDIA_TYPE_MUSIC);
bundle.putLong("bookmarkPosition", media.getBookmarkPosition() != null ? media.getBookmarkPosition() : 0);
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);
if (coverArtId != null) {
artworkUri = Uri.parse(CustomGlideRequest.createUrl(coverArtId, Preferences.getImageSize()));
}
return new MediaItem.Builder()
.setMediaId(media.getId())
.setMediaMetadata(
new MediaMetadata.Builder()
.setTitle(media.getTitle())
.setTrackNumber(media.getTrack() != null ? media.getTrack() : 0)
.setDiscNumber(media.getDiscNumber() != null ? media.getDiscNumber() : 0)
.setReleaseYear(media.getYear() != null ? media.getYear() : 0)
.setAlbumTitle(media.getAlbum())
.setArtist(media.getArtist())
.setArtworkUri(artworkUri)
.setUserRating(new HeartRating(media.getStarred() != null))
.setSupportedCommands(
Bundle bundle = new Bundle();
bundle.putString("id", media.getId());
bundle.putString("parentId", media.getParentId());
bundle.putBoolean("isDir", media.isDir());
bundle.putString("title", media.getTitle());
bundle.putString("album", media.getAlbum());
bundle.putString("artist", media.getArtist());
bundle.putInt("track", media.getTrack() != null ? media.getTrack() : 0);
bundle.putInt("year", media.getYear() != null ? media.getYear() : 0);
bundle.putString("genre", media.getGenre());
bundle.putString("coverArtId", coverArtId);
bundle.putLong("size", media.getSize() != null ? media.getSize() : 0);
bundle.putString("contentType", media.getContentType());
bundle.putString("suffix", media.getSuffix());
bundle.putString("transcodedContentType", media.getTranscodedContentType());
bundle.putString("transcodedSuffix", media.getTranscodedSuffix());
bundle.putInt("duration", media.getDuration() != null ? media.getDuration() : 0);
bundle.putInt("bitrate", media.getBitrate() != null ? media.getBitrate() : 0);
bundle.putInt("samplingRate", media.getSamplingRate() != null ? media.getSamplingRate() : 0);
bundle.putInt("bitDepth", media.getBitDepth() != null ? media.getBitDepth() : 0);
bundle.putString("path", media.getPath());
bundle.putBoolean("isVideo", media.isVideo());
bundle.putInt("userRating", media.getUserRating() != null ? media.getUserRating() : 0);
bundle.putDouble("averageRating", media.getAverageRating() != null ? media.getAverageRating() : 0);
bundle.putLong("playCount", media.getPlayCount() != null ? media.getPlayCount() : 0);
bundle.putInt("discNumber", media.getDiscNumber() != null ? media.getDiscNumber() : 0);
bundle.putLong("created", media.getCreated() != null ? media.getCreated().getTime() : 0);
bundle.putLong("starred", media.getStarred() != null ? media.getStarred().getTime() : 0);
bundle.putString("albumId", media.getAlbumId());
bundle.putString("artistId", media.getArtistId());
bundle.putString("type", Constants.MEDIA_TYPE_MUSIC);
bundle.putLong("bookmarkPosition", media.getBookmarkPosition() != null ? media.getBookmarkPosition() : 0);
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", media.getId() != null ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, media.getId()) : null);
bundle.putString("assetLinkAlbum", media.getAlbumId() != null ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, media.getAlbumId()) : null);
bundle.putString("assetLinkArtist", media.getArtistId() != null ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, media.getArtistId()) : null);
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())
.setMediaMetadata(
new MediaMetadata.Builder()
.setTitle(media.getTitle())
.setTrackNumber(media.getTrack() != null ? media.getTrack() : 0)
.setDiscNumber(media.getDiscNumber() != null ? media.getDiscNumber() : 0)
.setReleaseYear(media.getYear() != null ? media.getYear() : 0)
.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)
.build()
)
)
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
.build()
)
.setRequestMetadata(
new MediaItem.RequestMetadata.Builder()
.setMediaUri(uri)
.setExtras(bundle)
.build()
)
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)
.setUri(uri)
.build();
} catch (Exception e) {
String id = media != null ? media.getId() : "NULL_MEDIA_OBJECT";
String title = media != null ? media.getTitle() : "N/A";
Log.e(TAG, "Instant Mix CRASH! Failed to map song to MediaItem. " +
"Problematic Song ID: " + id +
", Title: " + title +
". Inspect this song's Subsonic data for missing fields.", e);
throw new RuntimeException("Mapping failed for song ID: " + id, e);
}
}
public static MediaItem mapMediaItem(MediaItem old) {
String mediaId = null;
if (old.requestMetadata.extras != null)
mediaId = old.requestMetadata.extras.getString("id");
if (mediaId != null && DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(mediaId)) {
return old;
}
Uri uri = old.requestMetadata.mediaUri == null ? null : MusicUtil.updateStreamUri(old.requestMetadata.mediaUri);
return new MediaItem.Builder()
.setMediaId(old.mediaId)
.setMediaMetadata(old.mediaMetadata)
.setRequestMetadata(
new MediaItem.RequestMetadata.Builder()
.setMediaUri(uri)
.setExtras(bundle)
.setExtras(old.requestMetadata.extras)
.build()
)
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)

View File

@@ -21,12 +21,17 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class MusicUtil {
private static final String TAG = "MusicUtil";
public static Uri getStreamUri(String id) {
private static final Pattern BITRATE_PATTERN = Pattern.compile("&maxBitRate=\\d+");
private static final Pattern FORMAT_PATTERN = Pattern.compile("&format=\\w+");
public static Uri getStreamUri(String id, int timeOffset) {
Map<String, String> params = App.getSubsonicClientInstance(false).getParams();
StringBuilder uri = new StringBuilder();
@@ -53,6 +58,8 @@ public class MusicUtil {
uri.append("&format=").append(getTranscodingFormatPreference());
if (Preferences.askForEstimateContentLength())
uri.append("&estimateContentLength=true");
if (timeOffset > 0)
uri.append("&timeOffset=").append(timeOffset);
uri.append("&id=").append(id);
@@ -61,6 +68,28 @@ public class MusicUtil {
return Uri.parse(uri.toString());
}
public static Uri getStreamUri(String id) {
return getStreamUri(id, 0);
}
public static Uri updateStreamUri(Uri uri) {
String s = uri.toString();
Matcher m1 = BITRATE_PATTERN.matcher(s);
s = m1.replaceAll("");
Matcher m2 = FORMAT_PATTERN.matcher(s);
s = m2.replaceAll("");
s = s.replace("&estimateContentLength=true", "");
if (!Preferences.isServerPrioritized())
s += "&maxBitRate=" + getBitratePreference();
if (!Preferences.isServerPrioritized())
s += "&format=" + getTranscodingFormatPreference();
if (Preferences.askForEstimateContentLength())
s += "&estimateContentLength=true";
return Uri.parse(s);
}
public static Uri getDownloadUri(String id) {
StringBuilder uri = new StringBuilder();

View File

@@ -70,15 +70,23 @@ object Preferences {
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 GITHUB_UPDATE_CHECK = "github_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 HOME_SORT_PLAYLISTS = "home_sort_playlists"
private const val DEFAULT_HOME_SORT_PLAYLISTS_SORT_ORDER = Constants.PLAYLIST_ORDER_BY_RANDOM
private const val EQUALIZER_ENABLED = "equalizer_enabled"
private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels"
private const val MINI_SHUFFLE_BUTTON_VISIBILITY = "mini_shuffle_button_visibility"
private const val ALBUM_DETAIL = "album_detail"
private const val ALBUM_SORT_ORDER = "album_sort_order"
private const val DEFAULT_ALBUM_SORT_ORDER = Constants.ALBUM_ORDER_BY_NAME
private const val ARTIST_SORT_BY_ALBUM_COUNT= "artist_sort_by_album_count"
private const val SORT_SEARCH_CHRONOLOGICALLY= "sort_search_chronologically"
private const val ARTIST_DISPLAY_BIOGRAPHY= "artist_display_biography"
private const val NETWORK_PING_TIMEOUT = "network_ping_timeout_base"
@JvmStatic
fun getServer(): String? {
@@ -90,6 +98,19 @@ object Preferences {
App.getInstance().preferences.edit().putString(SERVER, server).apply()
}
@JvmStatic
fun getNetworkPingTimeout(): Int {
val timeoutString = App.getInstance().preferences.getString(NETWORK_PING_TIMEOUT, "2") ?: "2"
return (timeoutString.toIntOrNull() ?: 2).coerceAtLeast(1)
}
@JvmStatic
fun setNetworkPingTimeout(pingTimeout: String?) {
App.getInstance().preferences.edit().putString(NETWORK_PING_TIMEOUT, pingTimeout).apply()
}
@JvmStatic
fun getUser(): String? {
return App.getInstance().preferences.getString(USER, null)
@@ -573,15 +594,21 @@ object Preferences {
return App.getInstance().preferences.getBoolean(RATING_PER_ITEM, false)
}
@JvmStatic
fun showTempoUpdateDialog(): Boolean {
fun isGithubUpdateEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(GITHUB_UPDATE_CHECK, true)
}
@JvmStatic
fun showTempusUpdateDialog(): Boolean {
return App.getInstance().preferences.getLong(
NEXT_UPDATE_CHECK, 0
) + 86400000 < System.currentTimeMillis()
}
@JvmStatic
fun setTempoUpdateReminder() {
fun setTempusUpdateReminder() {
App.getInstance().preferences.edit().putLong(NEXT_UPDATE_CHECK, System.currentTimeMillis()).apply()
}
@@ -615,6 +642,16 @@ object Preferences {
return App.getInstance().preferences.getBoolean(ALLOW_PLAYLIST_DUPLICATES, false)
}
@JvmStatic
fun getHomeSortPlaylists(): String {
return App.getInstance().preferences.getString(HOME_SORT_PLAYLISTS, DEFAULT_HOME_SORT_PLAYLISTS_SORT_ORDER) ?: DEFAULT_HOME_SORT_PLAYLISTS_SORT_ORDER
}
@JvmStatic
fun getHomeSortPlaylists(sortOrder: String) {
App.getInstance().preferences.edit().putString(HOME_SORT_PLAYLISTS, sortOrder).apply()
}
@JvmStatic
fun setEqualizerEnabled(enabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(EQUALIZER_ENABLED, enabled).apply()
@@ -656,4 +693,29 @@ object Preferences {
fun setAlbumSortOrder(sortOrder: String) {
App.getInstance().preferences.edit().putString(ALBUM_SORT_ORDER, sortOrder).apply()
}
@JvmStatic
fun getArtistSortOrder(): String {
val sort_by_album_count = App.getInstance().preferences.getBoolean(ARTIST_SORT_BY_ALBUM_COUNT, false)
Log.d("Preferences", "getSortOrder")
if (sort_by_album_count)
return Constants.ARTIST_ORDER_BY_ALBUM_COUNT
else
return Constants.ARTIST_ORDER_BY_NAME
}
@JvmStatic
fun isSearchSortingChronologicallyEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(SORT_SEARCH_CHRONOLOGICALLY, false)
}
@JvmStatic
fun getArtistDisplayBiography(): Boolean {
return App.getInstance().preferences.getBoolean(ARTIST_DISPLAY_BIOGRAPHY, true)
}
@JvmStatic
fun setArtistDisplayBiography(displayBiographyEnabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(ARTIST_DISPLAY_BIOGRAPHY, displayBiographyEnabled).apply()
}
}

View File

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

View File

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

View File

@@ -105,7 +105,11 @@ public class UIUtil {
}
public static String getReadableDate(Date date) {
if (date == null) {
return App.getContext().getString(R.string.share_no_expiration);
}
SimpleDateFormat formatter = new SimpleDateFormat("dd MMM, yyyy", Locale.getDefault());
return formatter.format(date);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.interfaces.MediaCallback;
import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.repository.AlbumRepository;
@@ -21,6 +22,7 @@ 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.Constants.SeedType;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.NetworkUtil;
@@ -128,11 +130,22 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
public LiveData<List<Child>> getInstantMix(LifecycleOwner owner, Child media) {
instantMix.setValue(Collections.emptyList());
songRepository.getInstantMix(media.getId(), 20).observe(owner, instantMix::postValue);
songRepository.getInstantMix(media.getId(), SeedType.TRACK, 30).observe(owner, instantMix::postValue);
return instantMix;
}
public void getInstantMix(Child media, int count, MediaCallback callback) {
songRepository.getInstantMix(media.getId(), SeedType.TRACK, count, songs -> {
if (songs != null && !songs.isEmpty()) {
callback.onLoadMedia(songs);
} else {
callback.onLoadMedia(Collections.emptyList());
}
});
}
public MutableLiveData<Share> shareTrack() {
return sharingRepository.createShare(song.getId(), song.getTitle(), null);
}

View File

@@ -37,7 +37,7 @@ public class SongListPageViewModel extends AndroidViewModel {
public int year = 0;
public int maxNumberByYear = 500;
public int maxNumberByGenre = 100;
public int maxNumberByGenre = 500;
public SongListPageViewModel(@NonNull Application application) {
super(application);
@@ -51,7 +51,7 @@ public class SongListPageViewModel extends AndroidViewModel {
switch (title) {
case Constants.MEDIA_BY_GENRE:
songList = songRepository.getSongsByGenre(genre.getGenre(), 0);
songList = songRepository.getRandomSampleWithGenre(maxNumberByGenre, 0, 3000, genre.getGenre());
break;
case Constants.MEDIA_BY_ARTIST:
songList = artistRepository.getTopSongs(artist.getName(), 50);

View File

@@ -14,22 +14,21 @@
app:layout_collapseMode="pin"
app:navigationIcon="@drawable/ic_arrow_back" />
<androidx.core.widget.NestedScrollView
android:id="@+id/fragment_album_page_nested_scroll_view"
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/album_info_sector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:paddingTop="8dp">
android:paddingTop="8dp"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<ImageView
android:id="@+id/album_cover_image_view"
@@ -175,7 +174,6 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/album_notes_textview" />
</androidx.constraintlayout.widget.ConstraintLayout>
<View
@@ -189,43 +187,69 @@
app:layout_constraintTop_toBottomOf="@+id/album_detail_view" />
<LinearLayout
android:id="@+id/album_page_button_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:gravity="center_vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/upper_button_divider">
<Button
android:id="@+id/album_page_play_button"
<LinearLayout
android:id="@+id/album_page_button_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="4dp"
android:layout_weight="1"
android:padding="10dp"
android:text="@string/album_page_play_button"
android:textAllCaps="false"
app:icon="@drawable/ic_play"
app:iconGravity="textStart"
app:iconPadding="18dp" />
android:orientation="horizontal"
android:gravity="center_vertical">
<Button
android:id="@+id/album_page_play_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:padding="10dp"
android:text="@string/album_page_play_button"
android:textAllCaps="false"
app:icon="@drawable/ic_play"
app:iconGravity="textStart"
app:iconPadding="18dp" />
<Button
android:id="@+id/album_page_shuffle_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:padding="10dp"
android:text="@string/album_page_shuffle_button"
android:textAllCaps="false"
app:icon="@drawable/ic_shuffle"
app:iconGravity="textStart"
app:iconPadding="18dp" />
</LinearLayout>
<ToggleButton
android:id="@+id/button_favorite"
android:layout_width="34dp"
android:layout_height="34dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="0dp"
android:background="@drawable/button_favorite_selector"
android:checked="false"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:gravity="center"
android:text=""
android:textOff=""
android:textOn="" />
<Button
android:id="@+id/album_page_shuffle_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:padding="10dp"
android:text="@string/album_page_shuffle_button"
android:textAllCaps="false"
app:icon="@drawable/ic_shuffle"
app:iconGravity="textStart"
app:iconPadding="18dp" />
</LinearLayout>
<TextView
@@ -240,7 +264,8 @@
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/album_page_button_layout" />
app:layout_constraintTop_toBottomOf="@id/album_page_button_layout"
tools:ignore="NotSibling" />
<View
android:id="@+id/bottom_button_divider"
@@ -250,55 +275,17 @@
android:layout_marginBottom="18dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/album_page_button_layout" />
app:layout_constraintTop_toBottomOf="@+id/album_bio_label" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/global_padding_bottom"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/song_recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingTop="8dp" />
<LinearLayout
android:id="@+id/similar_album_sector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<TextView
style="@style/TitleLarge"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingTop="32dp"
android:paddingEnd="20dp"
android:text="@string/album_page_extra_info_button" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/similar_albums_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:paddingEnd="8dp"
android:paddingBottom="8dp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/song_recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:paddingTop="8dp"
android:paddingBottom="@dimen/global_padding_bottom"
app:layout_behavior="@string/appbar_scrolling_view_behavior"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</LinearLayout>

View File

@@ -63,40 +63,80 @@
android:layout_marginEnd="18dp" />
<LinearLayout
android:id="@+id/album_page_button_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp">
android:paddingBottom="4dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:gravity="center_vertical">
<Button
android:id="@+id/artist_page_shuffle_button"
<LinearLayout
android:id="@+id/album_page_button_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="4dp"
android:layout_weight="1"
android:padding="10dp"
android:text="@string/artist_page_shuffle_button"
android:textAllCaps="false"
app:icon="@drawable/ic_shuffle"
app:iconGravity="textStart"
app:iconPadding="18dp" />
android:orientation="horizontal"
android:gravity="center_vertical">
<Button
android:id="@+id/artist_page_shuffle_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:padding="10dp"
android:text="@string/artist_page_shuffle_button"
android:textAllCaps="false"
app:icon="@drawable/ic_shuffle"
app:iconGravity="textStart"
app:iconPadding="18dp" />
<Button
android:id="@+id/artist_page_radio_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="6dp"
android:layout_marginEnd="6dp"
android:padding="10dp"
android:text="@string/artist_page_radio_button"
android:textAllCaps="false"
app:icon="@drawable/ic_feed"
app:iconGravity="textStart"
app:iconPadding="18dp" />
</LinearLayout>
<ToggleButton
android:id="@+id/button_favorite"
android:layout_width="34dp"
android:layout_height="34dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="0dp"
android:background="@drawable/button_favorite_selector"
android:checked="false"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:gravity="center"
android:text=""
android:textOff=""
android:textOn="" />
<Button
android:id="@+id/artist_page_radio_button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:padding="10dp"
android:text="@string/artist_page_radio_button"
android:textAllCaps="false"
app:icon="@drawable/ic_feed"
app:iconGravity="textStart"
app:iconPadding="18dp" />
android:id="@+id/button_toggle_bio"
android:layout_width="34dp"
android:layout_height="34dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="0dp"
android:background="@drawable/ic_info_stream"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:gravity="center"
android:text=""
android:textOff=""
android:textOn="" />
</LinearLayout>
<View

View File

@@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:orientation="vertical">
<fragment
android:id="@+id/toolbar_fragment"
@@ -26,6 +27,7 @@
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:paddingBottom="@dimen/global_padding_bottom"
android:visibility="gone">
<ImageView
@@ -57,92 +59,78 @@
android:text="@string/download_info_empty_subtitle" />
</LinearLayout>
<androidx.core.widget.NestedScrollView
android:id="@+id/fragment_download_nested_scroll_view"
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/download_downloaded_sector"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:visibility="gone"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
tools:visibility="visible">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/download_downloaded_sector"
<TextView
android:id="@+id/downloaded_text_view_refreshable"
style="@style/TitleLarge"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/download_title_section"
app:layout_constraintEnd_toStartOf="@+id/downloaded_refresh_image_view"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/shuffle_downloaded_text_view_clickable"
style="@style/TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingTop="16dp"
android:paddingBottom="@dimen/global_padding_bottom"
android:visibility="gone"
tools:visibility="visible">
android:text="@string/download_shuffle_all_subtitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/downloaded_text_view_refreshable" />
<TextView
android:id="@+id/downloaded_text_view_refreshable"
style="@style/TitleLarge"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/download_title_section"
app:layout_constraintEnd_toStartOf="@+id/downloaded_refresh_image_view"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/downloaded_refresh_image_view"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:background="@drawable/ic_refresh"
android:contentDescription="@string/download_refresh_button_content_description"
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
app:layout_constraintEnd_toStartOf="@id/downloaded_go_back_image_view"
app:layout_constraintStart_toEndOf="@id/downloaded_text_view_refreshable"
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
<TextView
android:id="@+id/shuffle_downloaded_text_view_clickable"
style="@style/TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/download_shuffle_all_subtitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/downloaded_text_view_refreshable"/>
<ImageView
android:id="@+id/downloaded_go_back_image_view"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:layout_marginHorizontal="12dp"
android:background="@drawable/ic_arrow_back"
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
app:layout_constraintEnd_toStartOf="@id/downloaded_group_by_image_view"
app:layout_constraintStart_toEndOf="@id/downloaded_refresh_image_view"
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
<ImageView
android:id="@+id/downloaded_refresh_image_view"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:background="@drawable/ic_refresh"
android:contentDescription="@string/download_refresh_button_content_description"
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
app:layout_constraintEnd_toStartOf="@id/downloaded_go_back_image_view"
app:layout_constraintStart_toEndOf="@id/downloaded_text_view_refreshable"
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
<ImageView
android:id="@+id/downloaded_group_by_image_view"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:background="@drawable/ic_filter_list"
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ImageView
android:id="@+id/downloaded_go_back_image_view"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginHorizontal="12dp"
android:layout_gravity="center"
android:background="@drawable/ic_arrow_back"
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
app:layout_constraintEnd_toStartOf="@id/downloaded_group_by_image_view"
app:layout_constraintStart_toEndOf="@id/downloaded_refresh_image_view"
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
<ImageView
android:id="@+id/downloaded_group_by_image_view"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"
android:background="@drawable/ic_filter_list"
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/downloaded_recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingTop="12dp"
android:paddingBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/shuffle_downloaded_text_view_clickable" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/downloaded_recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:nestedScrollingEnabled="false"
android:paddingHorizontal="12dp"
android:paddingTop="12dp"
android:paddingBottom="@dimen/global_padding_bottom" />
</LinearLayout>

View File

@@ -379,16 +379,6 @@
android:paddingTop="8dp"
android:paddingEnd="8dp"
android:paddingBottom="8dp" />
</LinearLayout>
<!-- Best of -->
<LinearLayout
android:id="@+id/home_best_of_artist_sector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:id="@+id/most_streamed_song_pre_text_view"
@@ -400,6 +390,16 @@
android:paddingEnd="16dp"
android:text="@string/home_subtitle_best_of"
android:textAllCaps="true" />
</LinearLayout>
<!-- Best of -->
<LinearLayout
android:id="@+id/home_best_of_artist_sector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:id="@+id/best_of_artist_text_view_refreshable"
@@ -566,6 +566,7 @@
android:paddingBottom="8dp" />
</LinearLayout>
<!--Starred Albums-->
<LinearLayout
android:id="@+id/starred_albums_sector"
android:layout_width="match_parent"
@@ -615,6 +616,7 @@
android:paddingBottom="8dp" />
</LinearLayout>
<!--Starred Artists-->
<LinearLayout
android:id="@+id/starred_artists_sector"
android:layout_width="match_parent"
@@ -913,16 +915,36 @@
android:visibility="gone"
tools:visibility="visible">
<!-- Label and button -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingTop="16dp"
android:paddingEnd="8dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/pinned_playlists_text_view"
style="@style/TitleLarge"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingTop="16dp"
android:paddingEnd="16dp"
android:layout_weight="1"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/home_title_pinned_playlists" />
<TextView
android:id="@+id/playlist_catalogue_text_view_clickable"
style="@style/TitleMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/library_title_playlist_see_all_button" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/pinned_playlists_recycler_view"
android:layout_width="match_parent"

View File

@@ -20,6 +20,20 @@
android:orientation="vertical"
android:paddingBottom="@dimen/global_padding_bottom">
<TextView
android:id="@+id/podcast_channels_pre_text_view"
style="@style/TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:paddingStart="16dp"
android:paddingTop="16dp"
android:paddingEnd="16dp"
android:text="@string/home_subtitle_new_podcast_channel"
android:textAllCaps="true"
android:textColor="?attr/colorPrimary"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/home_podcast_channels_sector"
android:layout_width="match_parent"
@@ -29,17 +43,6 @@
android:visibility="gone"
tools:visibility="visible">
<TextView
android:id="@+id/podcast_channels_pre_text_view"
style="@style/TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingTop="16dp"
android:paddingEnd="16dp"
android:text="@string/home_subtitle_new_podcast_channel"
android:textAllCaps="true" />
<!-- Label and button -->
<LinearLayout
android:layout_width="match_parent"
@@ -169,4 +172,4 @@
android:gravity="center"
android:text="@string/podcast_info_empty_button" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -1,44 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/internet_radio_station_pre_text_view"
style="@style/TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:paddingStart="16dp"
android:paddingTop="16dp"
android:paddingEnd="16dp"
android:text="@string/home_subtitle_new_internet_radio_station"
android:textAllCaps="true"
android:textColor="?attr/colorPrimary"
android:textStyle="bold"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/internet_radio_station_title_text_view"
style="@style/TitleLarge"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="@string/home_title_internet_radio_station"
app:layout_constraintTop_toBottomOf="@id/internet_radio_station_pre_text_view"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<androidx.core.widget.NestedScrollView
android:id="@+id/home_radio_station_sector"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
app:layout_constraintTop_toBottomOf="@id/internet_radio_station_title_text_view">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/global_padding_bottom">
<TextView
android:id="@+id/internet_radio_station_pre_text_view"
style="@style/TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingTop="16dp"
android:paddingEnd="16dp"
android:text="@string/home_subtitle_new_internet_radio_station"
android:textAllCaps="true" />
<TextView
android:id="@+id/internet_radio_station_title_text_view"
style="@style/TitleLarge"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="@string/home_title_internet_radio_station" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/internet_radio_station_recycler_view"
android:layout_width="match_parent"
@@ -61,7 +71,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
app:layout_constraintTop_toBottomOf="@id/internet_radio_station_title_text_view">
<ImageView
android:id="@+id/empty_description_image_view"
@@ -105,7 +115,4 @@
android:gravity="center"
android:text="@string/radio_station_info_empty_button" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -27,7 +27,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<ImageView
android:id="@+id/playlist_cover_image_view_top_left"

View File

@@ -16,6 +16,23 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<Button
android:id="@+id/player_playback_speed_button"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="80dp"
android:layout_height="48dp"
android:layout_marginLeft="0dp"
android:layout_marginTop="0dp"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
app:cornerRadius="30dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:tint="?attr/colorOnPrimaryContainer" />
<com.google.android.material.chip.Chip
android:id="@+id/player_media_extension"
style="@style/Widget.Material3.Chip.Suggestion"
@@ -253,23 +270,6 @@
app:layout_constraintStart_toEndOf="@+id/placeholder_view_middle_right"
app:layout_constraintTop_toTopOf="@+id/placeholder_view_middle_right" />
<Button
android:id="@+id/player_playback_speed_button"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginStart="24dp"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
app:cornerRadius="30dp"
app:layout_constraintBottom_toBottomOf="@+id/placeholder_view_middle_left"
app:layout_constraintEnd_toStartOf="@+id/placeholder_view_middle_left"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@+id/placeholder_view_middle_left"
app:tint="?attr/colorOnPrimaryContainer" />
<ImageButton
android:id="@+id/exo_shuffle"
android:layout_width="32dp"

View File

@@ -1,18 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/player_clean_queue_button"
style="@style/TitleMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:text="@string/player_queue_clean_all_button" />
<com.cappielloantonio.tempo.helper.recyclerview.NestedScrollableHost
android:layout_width="match_parent"
android:layout_height="match_parent">
@@ -21,20 +14,74 @@
android:id="@+id/player_queue_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="40dp"
android:paddingTop="8dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</com.cappielloantonio.tempo.helper.recyclerview.NestedScrollableHost>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/player_shuffle_queue_fab"
<LinearLayout
android:id="@+id/fab_menu_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="end"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/content_description_shuffle_button"
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"
app:srcCompat="@drawable/ic_shuffle" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior">
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_save_to_playlist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/player_queue_save_to_playlist"
app:icon="@android:drawable/ic_menu_edit" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_clear_queue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/player_queue_clean_all_button"
app:icon="@android:drawable/ic_menu_delete" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_download_all"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/menu_download_all_button"
app:icon="@android:drawable/stat_sys_download_done" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_load_queue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/player_queue_load_queue"
app:icon="@android:drawable/ic_menu_revert" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_shuffle_queue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:text="@string/content_description_shuffle_button"
app:icon="@drawable/ic_shuffle" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_menu_toggle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="Toggle FAB Action menu"
tools:ignore="HardcodedText"
app:srcCompat="@drawable/ic_add" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -7,7 +7,10 @@
<ImageView
android:id="@+id/discover_song_cover_image_view"
android:layout_width="match_parent"
android:layout_height="196dp"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:pivotX="50%"
android:pivotY="50%"
android:background="?attr/colorSurfaceContainerHighest"
android:foreground="@drawable/gradient_discover_background_image" />

View File

@@ -19,12 +19,15 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/cover_image_separator"
android:layout_width="12dp"
android:layout_height="52dp"
<ImageView
android:id="@+id/music_directory_play_button"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="12dp"
android:background="@drawable/ic_play"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="@+id/music_directory_cover_image_view"
app:layout_constraintEnd_toStartOf="@+id/music_directory_title_text_view"
app:layout_constraintStart_toEndOf="@+id/music_directory_cover_image_view"
app:layout_constraintTop_toTopOf="@+id/music_directory_cover_image_view" />
@@ -33,13 +36,14 @@
style="@style/LabelMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:ellipsize="marquee"
android:paddingEnd="12dp"
android:singleLine="true"
android:text="@string/label_placeholder"
app:layout_constraintBottom_toBottomOf="@id/music_directory_cover_image_view"
app:layout_constraintEnd_toStartOf="@+id/music_directory_more_button"
app:layout_constraintStart_toEndOf="@+id/cover_image_separator"
app:layout_constraintStart_toEndOf="@+id/music_directory_play_button"
app:layout_constraintTop_toTopOf="@+id/music_directory_cover_image_view" />
<ImageView
@@ -54,17 +58,4 @@
app:layout_constraintBottom_toBottomOf="@id/music_directory_title_text_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/music_directory_title_text_view" />
<ImageView
android:id="@+id/music_directory_play_button"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:background="@drawable/ic_play"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="@id/music_directory_title_text_view"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@+id/music_directory_title_text_view" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -20,12 +20,14 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/cover_image_separator"
android:layout_width="12dp"
android:layout_height="52dp"
<ImageView
android:id="@+id/music_index_play_button"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="12dp"
android:background="@drawable/ic_play"
android:foreground="?android:attr/selectableItemBackgroundBorderless"
app:layout_constraintBottom_toBottomOf="@+id/music_index_cover_image_view"
app:layout_constraintEnd_toStartOf="@+id/music_index_title_text_view"
app:layout_constraintStart_toEndOf="@+id/music_index_cover_image_view"
app:layout_constraintTop_toTopOf="@+id/music_index_cover_image_view" />
@@ -34,13 +36,14 @@
style="@style/LabelMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:ellipsize="marquee"
android:paddingEnd="12dp"
android:singleLine="true"
android:text="@string/label_placeholder"
app:layout_constraintBottom_toBottomOf="@id/music_index_cover_image_view"
app:layout_constraintEnd_toStartOf="@+id/music_index_more_button"
app:layout_constraintStart_toEndOf="@+id/cover_image_separator"
app:layout_constraintStart_toEndOf="@+id/music_index_play_button"
app:layout_constraintTop_toTopOf="@+id/music_index_cover_image_view" />
<ImageView

View File

@@ -139,6 +139,17 @@
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<ImageView
android:id="@+id/download_indicator_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp"
android:visibility="gone"
android:src="@drawable/ic_download" app:layout_constraintBottom_toBottomOf="@+id/queue_song_cover_image_view"
app:layout_constraintEnd_toStartOf="@+id/queue_song_holder_image"
app:layout_constraintTop_toTopOf="@+id/queue_song_cover_image_view"
tools:visibility="visible" />
<ImageView
android:id="@+id/queue_song_holder_image"
android:layout_width="wrap_content"

View File

@@ -6,4 +6,7 @@
<item
android:id="@+id/menu_artist_sort_random"
android:title="@string/menu_sort_random" />
<item
android:id="@+id/menu_artist_sort_album_count"
android:title="@string/menu_sort_album_count" />
</menu>

View File

@@ -65,6 +65,9 @@
<action
android:id="@+id/action_homeFragment_to_playlistPageFragment"
app:destination="@id/playlistPageFragment" />
<action
android:id="@+id/action_homeFragment_to_playlistCatalogueFragment"
app:destination="@id/playlistCatalogueFragment" />
<action
android:id="@+id/action_homeFragment_to_podcastChannelCatalogueFragment"
app:destination="@id/podcastChannelCatalogueFragment" />

View File

@@ -0,0 +1,258 @@
<?xml version="1.0"?>
<resources>
<string-array name="theme_list_titles">
<item>Clar</item>
<item>Fosc</item>
<item>Valor per defecte del sistema</item>
</string-array>
<string-array name="theme_list_values">
<item>light</item>
<item>dark</item>
<item>default</item>
</string-array>
<string-array name="pref_cache_size_titles">
<item>Alta</item>
<item>Mitjana</item>
<item>Baixa</item>
</string-array>
<string-array name="pref_cache_size_values">
<item>500</item>
<item>250</item>
<item>125</item>
</string-array>
<string-array name="pref_image_size_titles">
<item>Alta</item>
<item>Mitjana</item>
<item>Baixa</item>
</string-array>
<string-array name="pref_image_size_values">
<item>-1</item>
<item>500</item>
<item>300</item>
</string-array>
<string-array name="streaming_cache_size_titles">
<item>Inhabilitada</item>
<item>128 MiB</item>
<item>256 MiB</item>
<item>512 MiB</item>
<item>1024 MiB</item>
</string-array>
<string-array name="streaming_cache_size_values">
<item>0</item>
<item>128</item>
<item>256</item>
<item>512</item>
<item>1024</item>
</string-array>
<string-array name="max_bitrate_wifi_list_titles">
<item>Original</item>
<item>32 kbps</item>
<item>48 kbps</item>
<item>64 kbps</item>
<item>80 kbps</item>
<item>96 kbps</item>
<item>112 kbps</item>
<item>128 kbps</item>
<item>160 kbps</item>
<item>192 kbps</item>
<item>256 kbps</item>
<item>320 kbps</item>
</string-array>
<string-array name="max_bitrate_wifi_list_values">
<item>0</item>
<item>32</item>
<item>48</item>
<item>64</item>
<item>80</item>
<item>96</item>
<item>112</item>
<item>128</item>
<item>160</item>
<item>192</item>
<item>256</item>
<item>320</item>
</string-array>
<string-array name="max_bitrate_mobile_list_titles">
<item>Original</item>
<item>32 kbps</item>
<item>48 kbps</item>
<item>64 kbps</item>
<item>80 kbps</item>
<item>96 kbps</item>
<item>112 kbps</item>
<item>128 kbps</item>
<item>160 kbps</item>
<item>192 kbps</item>
<item>256 kbps</item>
<item>320 kbps</item>
</string-array>
<string-array name="max_bitrate_mobile_list_values">
<item>0</item>
<item>32</item>
<item>48</item>
<item>64</item>
<item>80</item>
<item>96</item>
<item>112</item>
<item>128</item>
<item>160</item>
<item>192</item>
<item>256</item>
<item>320</item>
</string-array>
<string-array name="max_bitrate_download_list_titles">
<item>Original</item>
<item>32 kbps</item>
<item>48 kbps</item>
<item>64 kbps</item>
<item>80 kbps</item>
<item>96 kbps</item>
<item>112 kbps</item>
<item>128 kbps</item>
<item>160 kbps</item>
<item>192 kbps</item>
<item>256 kbps</item>
<item>320 kbps</item>
</string-array>
<string-array name="max_bitrate_download_list_values">
<item>0</item>
<item>32</item>
<item>48</item>
<item>64</item>
<item>80</item>
<item>96</item>
<item>112</item>
<item>128</item>
<item>160</item>
<item>192</item>
<item>256</item>
<item>320</item>
</string-array>
<string-array name="audio_transcode_format_wifi_list_titles">
<item>Reproducció directa</item>
<item>Opus</item>
<item>AAC</item>
<item>MP3</item>
<item>FLAC</item>
</string-array>
<string-array name="audio_transcode_format_wifi_list_values">
<item>raw</item>
<item>opus</item>
<item>aac</item>
<item>mp3</item>
<item>flac</item>
</string-array>
<string-array name="audio_transcode_format_mobile_list_titles">
<item>Reproducció directa</item>
<item>Opus</item>
<item>AAC</item>
<item>MP3</item>
<item>FLAC</item>
</string-array>
<string-array name="audio_transcode_format_mobile_list_values">
<item>raw</item>
<item>opus</item>
<item>aac</item>
<item>mp3</item>
<item>flac</item>
</string-array>
<string-array name="audio_transcode_format_download_list_titles">
<item>Baixada directa</item>
<item>Opus</item>
<item>AAC</item>
<item>MP3</item>
<item>FLAC</item>
</string-array>
<string-array name="audio_transcode_format_download_list_values">
<item>raw</item>
<item>opus</item>
<item>aac</item>
<item>mp3</item>
<item>flac</item>
</string-array>
<string-array name="queue_syncing_countdown_titles">
<item>10 segons</item>
<item>5 segons</item>
<item>2 segons</item>
</string-array>
<string-array name="queue_syncing_countdown_values">
<item>10</item>
<item>5</item>
<item>2</item>
</string-array>
<string-array name="rounded_corner_size_titles">
<item>Alta</item>
<item>Mitjana</item>
<item>Baixa</item>
</string-array>
<string-array name="rounded_corner_size_values">
<item>18</item>
<item>12</item>
<item>6</item>
</string-array>
<string-array name="replay_gain_titles">
<item>Inhabilitat</item>
<item>Pista</item>
<item>Àlbum</item>
<item>Automàtic</item>
</string-array>
<string-array name="replay_gain_values">
<item>disabled</item>
<item>track</item>
<item>album</item>
<item>auto</item>
</string-array>
<string-array name="transcoded_download_option_list_titles">
<item>Sense transcodificació</item>
<item>Paràmetres del servidor</item>
<item>Format de transcodificació amb wifi</item>
<item>Format de transcodificació amb dades mòbils</item>
</string-array>
<string-array name="transcoded_download_option_list_values">
<item>0</item>
<item>1</item>
<item>2</item>
<item>3</item>
</string-array>
<string-array name="buffering_strategy_titles">
<item>Mínima</item>
<item>Moderada</item>
<item>Agressiva</item>
<item>Extrema</item>
</string-array>
<string-array name="buffering_strategy_values">
<item>.1</item>
<item>1</item>
<item>4</item>
<item>8</item>
</string-array>
<string-array name="skip_min_star_rating_titles">
<item>Mínim 0 estrelles</item>
<item>Mínim 1 estrella</item>
<item>Mínim 2 estrelles</item>
<item>Mínim 3 estrelles</item>
<item>Mínim 4 estrelles</item>
</string-array>
<string-array name="skip_min_star_rating_values">
<item>0</item>
<item>1</item>
<item>2</item>
<item>3</item>
<item>4</item>
</string-array>
</resources>

View File

@@ -0,0 +1,537 @@
<?xml version="1.0"?>
<resources>
<string name="activity_battery_optimizations_conclusion">Si teniu problemes, visiteu https://dontkillmyapp.com. S\'hi proporcionen instruccions detallades sobre com inhabilitar qualsevol característica d\'estalvi de bateria que pugui afectar el rendiment de l\'aplicació.</string>
<string name="activity_battery_optimizations_summary">Inhabiliteu les optimitzacions de la bateria per a la reproducció multimèdia mentre la pantalla està apagada.</string>
<string name="activity_battery_optimizations_title">Optimitzacions de la bateria</string>
<string name="activity_info_offline_mode">Mode fora de línia</string>
<string name="album_bottom_sheet_add_to_playlist">Afegeix a la llista de reproducció</string>
<string name="album_bottom_sheet_add_to_queue">Afegeix a la cua</string>
<string name="album_bottom_sheet_download_all">Baixa-ho tot</string>
<string name="album_bottom_sheet_go_to_artist">Ves a l\'artista</string>
<string name="album_bottom_sheet_instant_mix">Mescla instantània</string>
<string name="album_bottom_sheet_play_next">Reprodueix a continuació</string>
<string name="album_bottom_sheet_remove_all">Suprimeix-ho tot</string>
<string name="album_bottom_sheet_share">Comparteix</string>
<string name="album_bottom_sheet_shuffle">Reprodueix aleatòriament</string>
<string name="album_catalogue_title">Àlbums</string>
<string name="album_catalogue_title_expanded">Exploració d\'àlbums</string>
<string name="album_error_retrieving_artist">S\'ha produït un error en recuperar l\'artista</string>
<string name="album_list_page_downloaded">Àlbums baixats</string>
<string name="album_list_page_most_played">Àlbums més reproduïts</string>
<string name="album_list_page_new_releases">Llançaments nous</string>
<string name="album_list_page_recently_added">Àlbums afegits recentment</string>
<string name="album_list_page_recently_played">Àlbums reproduïts recentment</string>
<string name="album_list_page_starred">Àlbums amb estrelles</string>
<string name="album_list_page_title">Àlbums</string>
<string name="album_page_extra_info_button">Més com això</string>
<string name="album_page_play_button">Reprodueix</string>
<string name="album_page_release_date_label">Publicació: %1$s</string>
<string name="album_page_release_dates_label">Publicació: %1$s, originalment: %2$s</string>
<string name="album_page_shuffle_button">Reprodueix aleatòriament</string>
<string name="album_page_tracks_count_and_duration">%1$d cançons • %2$d minuts</string>
<string name="app_name">Tempus</string>
<string name="artist_adapter_radio_station_starting">S\'està cercant...</string>
<string name="artist_bottom_sheet_instant_mix">Mescla instantània</string>
<string name="artist_bottom_sheet_shuffle">Reprodueix aleatòriament</string>
<string name="artist_catalogue_title">Artistes</string>
<string name="artist_catalogue_title_expanded">Exploració d\'artistes</string>
<string name="artist_error_retrieving_radio">S\'ha produït un error en recuperar la ràdio de l\'artista</string>
<string name="artist_error_retrieving_tracks">S\'ha produït un error en recuperar les pistes de l\'artista</string>
<string name="artist_list_page_downloaded">Artistes baixats</string>
<string name="artist_list_page_starred">Artistes amb estrelles</string>
<string name="artist_list_page_title">Artistes</string>
<string name="artist_page_radio_button">Ràdio</string>
<string name="artist_page_shuffle_button">Reprodueix aleatòriament</string>
<string name="artist_page_switch_layout_button">Canvia de disposició</string>
<string name="artist_page_title_album_more_like_this_button">Més com això</string>
<string name="artist_page_title_album_section">Àlbums</string>
<string name="artist_page_title_biography_more_button">Més</string>
<string name="artist_page_title_biography_section">Biografia</string>
<string name="artist_page_title_most_streamed_song_section">Cançons més transmeses</string>
<string name="artist_page_title_most_streamed_song_see_all_button">Visualitza-ho tot</string>
<string name="battery_optimization_negative_button">Ignora</string>
<string name="battery_optimization_neutral_button">No ho tornis a preguntar</string>
<string name="battery_optimization_positive_button">Inhabilita</string>
<string name="connection_alert_dialog_negative_button">Cancel·la</string>
<string name="connection_alert_dialog_neutral_button">Habilita l\'estalvi de dades</string>
<string name="connection_alert_dialog_positive_button">D\'acord</string>
<string name="connection_alert_dialog_summary">S\'ha restringit l\'accés al servidor del Subsonic en connexions que no són wifi. Per a impedir que torni a aparèixer aquesta alerta, inhabiliteu la comprovació de la connexió als paràmetres de l\'aplicació.</string>
<string name="connection_alert_dialog_title">Xarxa wifi no connectada</string>
<string name="content_description_shuffle_button">Reprodueix aleatòriament</string>
<string name="delete_download_storage_dialog_negative_button">Cancel·la</string>
<string name="delete_download_storage_dialog_positive_button">Continua</string>
<string name="delete_download_storage_dialog_summary">Tingueu en compte que, si continueu, se suprimiran permanentment tots els elements baixats de tots els servidors.</string>
<string name="delete_download_storage_dialog_title">Suprimeix els elements desats</string>
<string name="description_empty_title">No hi ha cap descripció disponible</string>
<string name="disc_titlefull">Disc %1$s - %2$s</string>
<string name="disc_titleless">Disc %1$s</string>
<string name="download_directory_dialog_negative_button">Cancel·la</string>
<string name="download_directory_dialog_positive_button">Baixa</string>
<string name="download_directory_dialog_summary">Es baixaran totes les pistes d\'aquesta carpeta. Les pistes en subcarpetes no es baixaran.</string>
<string name="download_directory_dialog_title">Baixada de les pistes</string>
<string name="download_directory_set">Defineix on es baixa la música</string>
<string name="download_info_empty_subtitle">Quan baixeu una cançó, la trobareu aquí.</string>
<string name="download_info_empty_title">Encara no hi ha cap baixada.</string>
<string name="download_item_multiple_subtitle_formatter">%1$s • %2$s elements</string>
<string name="download_item_single_subtitle_formatter">%1$s elements</string>
<string name="download_shuffle_all_subtitle">Reprodueix-ho tot aleatòriament</string>
<string name="download_storage_dialog_sub_summary">Perquè els canvis tinguin efecte, reinicieu l\'aplicació.</string>
<string name="download_storage_dialog_summary">Si canvieu la destinació dels fitxers baixats d\'un emmagatzematge a un altre, se suprimiran immediatament tots els fitxers baixats anteriorment de l\'altre emmagatzematge.</string>
<string name="download_storage_dialog_title">Selecció de l\'opció d\'emmagatzematge</string>
<string name="download_storage_external_dialog_positive_button">Extern</string>
<string name="download_storage_internal_dialog_negative_button">Intern</string>
<string name="download_storage_directory_dialog_neutral_button">Carpeta</string>
<string name="download_title_section">Baixades</string>
<string name="download_refresh_no_directory">Definiu una carpeta de baixades per a actualitzar les baixades.</string>
<string name="download_refresh_no_changes">No s\'ha trobat cap baixada que falti.</string>
<plurals name="download_refresh_removed">
<item quantity="one">S\'ha suprimit %d baixada que faltava.</item>
<item quantity="other">S\'han suprimit %d baixades que faltaven.</item>
</plurals>
<string name="download_refresh_button_content_description">Actualitza els elements baixats</string>
<string name="downloaded_bottom_sheet_add_to_queue">Afegeix a la cua</string>
<string name="downloaded_bottom_sheet_play_next">Reprodueix a continuació</string>
<string name="downloaded_bottom_sheet_remove">Suprimeix</string>
<string name="downloaded_bottom_sheet_remove_all">Suprimeix-ho tot</string>
<string name="downloaded_bottom_sheet_shuffle">Reprodueix aleatòriament</string>
<string name="empty_string"/>
<string name="error_required">Obligatori</string>
<string name="error_server_prefix">El prefix «http» o «https» és obligatori</string>
<string name="exo_download_notification_channel_name">Baixades</string>
<string name="exo_controls_heart_off_description">Treu el cor</string>
<string name="exo_controls_heart_on_description">Posa un cor</string>
<string name="cast_expanded_controller_loading">S\'està carregant...</string>
<string name="filter_info_selection">Seleccioneu dos o més filtres</string>
<string name="filter_title">Filtre</string>
<string name="filter_artist">Filtra per artistes</string>
<string name="filter_title_expanded">Filtra per gèneres</string>
<string name="generic_list_page_count">(%1$d)</string>
<string name="generic_list_page_count_unknown">(+%1$d)</string>
<string name="genre_catalogue_title">Catàleg de gèneres</string>
<string name="genre_catalogue_title_expanded">Exploració de gèneres</string>
<string name="github_update_dialog_negative_button">Recorda-m\'ho més tard</string>
<string name="github_update_dialog_neutral_button">Fes una aportació</string>
<string name="github_update_dialog_positive_button">Baixa-ho ara</string>
<string name="github_update_dialog_summary">Hi ha una versió nova de l\'aplicació disponible a Github.</string>
<string name="github_update_dialog_title">Actualització disponible</string>
<string name="home_rearrangement_dialog_negative_button">Cancel·la</string>
<string name="home_rearrangement_dialog_neutral_button">Reinicialitza</string>
<string name="home_rearrangement_dialog_positive_button">Desa</string>
<string name="home_rearrangement_dialog_title">Reorganització de l\'inici</string>
<string name="home_rearrangement_dialog_subtitle">Tingueu en compte que, perquè els canvis tinguin efecte, cal reiniciar l\'aplicació.</string>
<string name="home_section_music">Música</string>
<string name="home_section_podcast">Pòdcasts</string>
<string name="home_section_radio">Ràdio</string>
<string name="home_subtitle_best_of">Les millors cançons dels vostres artistes preferits</string>
<string name="home_subtitle_made_for_you">Comenceu una mescla a partir d\'una cançó que us ha agradat</string>
<string name="home_subtitle_new_internet_radio_station">Afegeix una ràdio nova</string>
<string name="home_subtitle_new_podcast_channel">Afegeix un canal de pòdcasts nou</string>
<string name="home_sync_starred_cancel">Cancel·la</string>
<string name="home_sync_starred_download">Baixa</string>
<string name="home_sync_starred_subtitle">Baixar aquestes pistes pot suposar un ús de dades important</string>
<string name="home_sync_starred_title">Sembla que hi ha pistes amb estrelles pendents de sincronitzar</string>
<string name="home_sync_starred_albums_title">Sincronitza els àlbums amb estrelles</string>
<string name="home_sync_starred_albums_subtitle">Els àlbums marcats amb una estrella estaran disponibles fora de línia</string>
<string name="home_sync_starred_artists_title">Sincronització dels artistes amb estrelles</string>
<string name="home_sync_starred_artists_subtitle">Teniu artistes amb estrella amb música sense baixar</string>
<string name="home_title_best_of">El millor de</string>
<string name="home_title_discovery">Descobriment</string>
<string name="home_title_discovery_shuffle_all_button">Reprodueix-ho tot aleatòriament</string>
<string name="home_title_flashback">Viatge al passat</string>
<string name="home_title_internet_radio_station">Emissores de ràdio per Internet</string>
<string name="home_title_last_played">Darreres reproduccions</string>
<string name="home_title_last_played_see_all_button">Visualitza-ho tot</string>
<string name="home_title_last_week">La setmana passada</string>
<string name="home_title_last_month">El mes passat</string>
<string name="home_title_last_year">L\'any passat</string>
<string name="home_title_made_for_you">Fet a mida</string>
<string name="home_title_most_played">Més reproduccions</string>
<string name="home_title_most_played_see_all_button">Visualitza-ho tot</string>
<string name="home_title_new_releases">Llançaments nous</string>
<string name="home_title_newest_podcasts">Pòdcasts més recents</string>
<string name="home_title_pinned_playlists">Llistes de reproducció</string>
<string name="home_title_podcast_channels">Canals</string>
<string name="home_title_podcast_channels_see_all_button">Visualitza-ho tot</string>
<string name="home_title_radio_station">Emissores de ràdio</string>
<string name="home_title_recently_added">Addicions recents</string>
<string name="home_title_recently_added_see_all_button">Visualitza-ho tot</string>
<string name="home_title_shares">Elements compartits</string>
<string name="home_title_starred_albums">★ Àlbums amb estrelles</string>
<string name="home_title_starred_albums_see_all_button">Visualitza-ho tot</string>
<string name="home_title_starred_artists">★ Artistes amb estrelles</string>
<string name="home_title_starred_artists_see_all_button">Visualitza-ho tot</string>
<string name="home_title_starred_tracks">★ Pistes amb estrelles</string>
<string name="home_title_starred_tracks_see_all_button">Visualitza-ho tot</string>
<string name="home_title_top_songs">Les vostres cançons més escoltades</string>
<string name="home_option_reorganize">Reorganitza</string>
<string name="label_dot_separator" translatable="false"></string>
<string name="label_placeholder" translatable="false">--</string>
<string name="library_title_album">Àlbums</string>
<string name="library_title_album_see_all_button">Visualitza-ho tot</string>
<string name="library_title_artist">Artistes</string>
<string name="library_title_artist_see_all_button">Visualitza-ho tot</string>
<string name="library_title_genre">Gèneres</string>
<string name="library_title_genre_see_all_button">Visualitza-ho tot</string>
<string name="library_title_music_folder">Carpetes de música</string>
<string name="library_title_playlist">Llistes de reproducció</string>
<string name="library_title_playlist_see_all_button">Visualitza-ho tot</string>
<string name="login_empty">No s\'ha afegit cap servidor</string>
<string name="login_title">Servidors del Subsonic</string>
<string name="login_title_expanded">Servidors del Subsonic</string>
<string name="media_route_menu_title">Emissió</string>
<string name="menu_add_button">Afegeix</string>
<string name="menu_add_to_playlist_button">Afegeix a la llista de reproducció</string>
<string name="menu_download_all_button">Baixa-ho tot</string>
<string name="menu_rate_album">Valora l\'àlbum</string>
<string name="menu_download_label">Baixa</string>
<string name="menu_filter_all">Tot</string>
<string name="menu_filter_download">Baixades</string>
<string name="menu_group_by_album">Àlbum</string>
<string name="menu_group_by_artist">Artista</string>
<string name="menu_group_by_genre">Gènere</string>
<string name="menu_group_by_track">Pista</string>
<string name="menu_group_by_year">Any</string>
<string name="menu_home_label">Inici</string>
<string name="menu_last_week_name">La setmana passada</string>
<string name="menu_last_month_name">El mes passat</string>
<string name="menu_last_year_name">L\'any passat</string>
<string name="menu_library_label">Biblioteca</string>
<string name="menu_search_button">Cerca</string>
<string name="menu_settings_button">Paràmetres</string>
<string name="menu_sort_artist">Artista</string>
<string name="menu_sort_name">Nom</string>
<string name="menu_sort_random">Aleatori</string>
<string name="menu_sort_album_count">Nombre d\'àlbums</string>
<string name="menu_sort_recently_added">Addicions recents</string>
<string name="menu_sort_recently_played">Reproduccions recents</string>
<string name="menu_sort_most_played">Més reproduccions</string>
<string name="menu_sort_most_recently_starred">Estrelles més recents</string>
<string name="menu_sort_least_recently_starred">Estrelles menys recents</string>
<string name="menu_pin_button">Afegeix a la pantalla d\'inici</string>
<string name="menu_unpin_button">Suprimeix de la pantalla d\'inici</string>
<string name="menu_sort_year">Any</string>
<string name="player_playback_speed">%1$.2fx</string>
<string name="player_queue_clean_all_button">Esborra la cua de reproducció</string>
<string name="player_queue_save_queue_success">S\'ha desat la cua de reproducció</string>
<string name="player_lyrics_download_content_description">Baixa les lletres per a la reproducció fora de línia</string>
<string name="player_lyrics_downloaded_content_description">Lletres baixades per a la reproducció fora de línia</string>
<string name="player_lyrics_download_success">S\'han desat les lletres per a la reproducció fora de línia.</string>
<string name="player_lyrics_download_failure">No hi ha lletres disponibles que es puguin baixar.</string>
<string name="player_server_priority">Prioritat dels servidors</string>
<string name="player_unknown_format">Format desconegut</string>
<string name="player_transcoding">Transcodificació</string>
<string name="player_transcoding_requested">sol·licitat</string>
<string name="playlist_catalogue_title">Catàleg de llistes de reproducció</string>
<string name="playlist_catalogue_title_expanded">Exploració de llistes de reproducció</string>
<string name="playlist_chooser_dialog_empty">No s\'ha creat cap llista de reproducció</string>
<string name="playlist_chooser_dialog_negative_button">Cancel·la</string>
<string name="playlist_chooser_dialog_neutral_button">Crea</string>
<string name="playlist_chooser_dialog_title">Addició a una llista de reproducció</string>
<string name="playlist_chooser_dialog_toast_add_success">S\'han afegit les cançons a la llista de reproducció</string>
<string name="playlist_chooser_dialog_toast_add_failure">No s\'han pogut afegir les cançons a la llista de reproducció</string>
<string name="playlist_chooser_dialog_toast_all_skipped">S\'han omès totes les cançons com a duplicades</string>
<string name="playlist_counted_tracks">%1$d pistes • %2$s</string>
<string name="playlist_duration">Durada • %1$s</string>
<string name="playlist_editor_dialog_action_delete_toast">Manteniu-ho premut per a suprimir-ho</string>
<string name="playlist_editor_dialog_hint_name">Nom de la llista de reproducció</string>
<string name="playlist_editor_dialog_negative_button">Cancel·la</string>
<string name="playlist_editor_dialog_neutral_button">Suprimeix</string>
<string name="playlist_editor_dialog_positive_button">Desa</string>
<string name="playlist_editor_dialog_title">Edició de la llista de reproducció</string>
<string name="playlist_page_play_button">Reprodueix</string>
<string name="playlist_page_shuffle_button">Reprodueix aleatòriament</string>
<string name="playlist_song_count">Llista de reproducció • %1$d cançons</string>
<string name="podcast_bottom_sheet_add_to_queue">Afegeix a la cua</string>
<string name="podcast_bottom_sheet_delete">Suprimeix</string>
<string name="podcast_bottom_sheet_download">Baixa</string>
<string name="podcast_bottom_sheet_go_to_channel">Ves al canal</string>
<string name="podcast_bottom_sheet_play_next">Reprodueix a continuació</string>
<string name="podcast_bottom_sheet_remove">Suprimeix</string>
<string name="podcast_channel_catalogue_title">Canals</string>
<string name="podcast_channel_catalogue_title_expanded">Exploració de canals</string>
<string name="podcast_channel_editor_dialog_hint_rss_url">URL de l\'RSS</string>
<string name="podcast_channel_editor_dialog_title">Canal de pòdcast</string>
<string name="podcast_channel_page_title_description_section">Descripció</string>
<string name="podcast_channel_page_title_episode_section">Episodis</string>
<string name="podcast_channel_page_title_no_episode_available">No hi ha cap episodi disponible</string>
<string name="podcast_episode_download_request_snackbar">S\'ha enviat la sol·licitud al servidor</string>
<string name="podcast_info_empty_button">Feu clic per a ocultar la secció.\nEls efectes seran visibles quan reinicieu l\'aplicació.</string>
<string name="podcast_info_empty_subtitle">Quan afegiu un canal, el trobareu aquí.</string>
<string name="podcast_info_empty_title">No s\'ha trobat cap pòdcast.</string>
<string name="podcast_release_date_duration_formatter">%1$s • %2$s</string>
<string name="radio_editor_dialog_hint_homepage_url">URL de la pàgina d\'inici de la ràdio</string>
<string name="radio_editor_dialog_hint_name">Nom de la ràdio</string>
<string name="radio_editor_dialog_hint_stream_url">URL de la transmissió de la ràdio</string>
<string name="radio_editor_dialog_negative_button">Cancel·la</string>
<string name="radio_editor_dialog_neutral_button">Suprimeix</string>
<string name="radio_editor_dialog_positive_button">Desa</string>
<string name="radio_editor_dialog_title">Emissora de ràdio per Internet</string>
<string name="radio_station_info_empty_button">Feu clic per a ocultar la secció.\nEls efectes seran visibles quan reinicieu l\'aplicació.</string>
<string name="radio_station_info_empty_subtitle">Quan afegiu una emissora de ràdio, la trobareu aquí.</string>
<string name="radio_station_info_empty_title">No s\'ha trobat cap emissora.</string>
<string name="rating_dialog_negative_button">Cancel·la</string>
<string name="rating_dialog_positive_button">Desa</string>
<string name="rating_dialog_title">Valoració</string>
<string name="search_hint">Cerqueu títols, artistes o àlbums</string>
<string name="search_info_minimum_characters">Introduïu com a mínim tres caràcters</string>
<string name="search_title_album">Àlbums</string>
<string name="search_title_artist">Artistes</string>
<string name="search_title_song">Cançons</string>
<string name="server_signup_dialog_action_low_security">Seguretat baixa</string>
<string name="server_signup_dialog_action_delete_toast">Manteniu-ho premut per a suprimir-ho</string>
<string name="server_signup_dialog_hint_local_address">URL local</string>
<string name="server_signup_dialog_hint_name">Nom del servidor</string>
<string name="server_signup_dialog_hint_password">Contrasenya</string>
<string name="server_signup_dialog_hint_url">URL del servidor</string>
<string name="server_signup_dialog_hint_username">Nom d\'usuari</string>
<string name="server_signup_dialog_negative_button">Cancel·la</string>
<string name="server_signup_dialog_neutral_button">Suprimeix</string>
<string name="server_signup_dialog_positive_button">Desa</string>
<string name="server_signup_dialog_title">Addició d\'un servidor</string>
<string name="server_unreachable_dialog_negative_button">Cancel·la</string>
<string name="server_unreachable_dialog_neutral_button">Ves a l\'inici de sessió</string>
<string name="server_unreachable_dialog_positive_button">Continua igualment</string>
<string name="server_unreachable_dialog_summary">El servidor sol·licitat no està disponible. Si trieu continuar, aquest quadre de diàleg no apareixerà durant una hora.</string>
<string name="server_unreachable_dialog_title">Servidor no disponible</string>
<string name="settings_about_summary">Tempus és un client de música lliure i lleuger per al Subsonic, dissenyat i creat nativament per a l\'Android.</string>
<string name="settings_about_title">Quant a</string>
<string name="settings_always_on_display">Pantalla sempre encesa</string>
<string name="settings_allow_playlist_duplicates">Permet afegir duplicats a una llista de reproducció</string>
<string name="settings_allow_playlist_duplicates_summary">Si s\'habilita, no es comprovarà si hi ha elements duplicats en afegir-los a una llista de reproducció.</string>
<string name="settings_audio_transcode_download_format">Format de transcodificació</string>
<string name="settings_audio_transcode_download_priority_summary">Si s\'habilita, Tempus no forçarà la baixada de la pista amb els paràmetres de transcodificació següents.</string>
<string name="settings_audio_transcode_download_priority_title">Prioritza els paràmetres del servidor per a la transmissió a les baixades</string>
<string name="settings_audio_transcode_download_summary">Si s\'habilita, Tempus baixarà les pistes transcodificades.</string>
<string name="settings_audio_transcode_download_title">Baixa les pistes transcodificades</string>
<string name="settings_audio_transcode_estimate_content_length_summary">Si s\'habilita, es demanarà al servidor la durada estimada de la pista.</string>
<string name="settings_audio_transcode_estimate_content_length_title">Estima la durada del contingut</string>
<string name="settings_audio_transcode_format_download">Format de transcodificació per a les baixades</string>
<string name="settings_audio_transcode_format_mobile">Format de transcodificació amb dades mòbils</string>
<string name="settings_audio_transcode_format_wifi">Format de transcodificació amb wifi</string>
<string name="settings_audio_transcode_priority_summary">Si s\'habilita, Tempus no forçarà la transmissió de la pista amb els paràmetres de transcodificació següents.</string>
<string name="settings_audio_transcode_priority_title">Prioritza els paràmetres de transcodificació del servidor</string>
<string name="settings_audio_transcode_priority_toast">Prioritat de transcodificació de la pista proporcionada al servidor</string>
<string name="settings_buffering_strategy">Estratègia de memòria intermèdia</string>
<string name="settings_buffering_strategy_summary">Perquè el canvi tingui efecte, heu de reiniciar l\'aplicació manualment.</string>
<string name="settings_choose_download_folder">Trieu una carpeta per als fitxers de música baixats.</string>
<string name="settings_clear_download_folder">Esborra la carpeta de baixades</string>
<string name="settings_continuous_play_summary">Permet que segueixi sonant música quan acabi una llista de reproducció; es reproduiran cançons similars.</string>
<string name="settings_continuous_play_title">Reproducció contínua</string>
<string name="settings_covers_cache">Mida de la memòria cau de les caràtules</string>
<string name="settings_data_saving_mode_summary">Per a reduir el consum de dades, evita la baixada de les caràtules.</string>
<string name="settings_data_saving_mode_title">Limita l\'ús de les dades mòbils</string>
<string name="settings_delete_download_storage_summary">Si continueu, se suprimiran permanentment tots els elements desats.</string>
<string name="settings_delete_download_storage_title">Suprimeix els elements desats</string>
<string name="settings_download_storage_title">Emmagatzematge per a les baixades.</string>
<string name="settings_download_folder_cleared">S\'ha esborrat la carpeta de baixades.</string>
<string name="settings_download_folder_set">S\'ha definit la carpeta de baixades.</string>
<string name="settings_set_download_folder">Defineix la carpeta de baixades</string>
<string name="settings_system_equalizer_summary">Ajusteu els paràmetres d\'àudio</string>
<string name="settings_system_equalizer_title">Equalitzador del sistema</string>
<string name="settings_github_link">https://github.com/eddyizm/tempus</string>
<string name="settings_github_summary">Seguiu el desenvolupament</string>
<string name="settings_github_title">Github</string>
<string name="settings_support_discussion_link">https://github.com/eddyizm/tempus/discussions</string>
<string name="settings_github_update">Actualitzacions</string>
<string name="settings_github_update_title">Comprova si hi ha actualitzacions a GitHub</string>
<string name="settings_github_update_summary">Si feu servir la versió de GitHub, per defecte l\'aplicació comprovarà si hi ha noves versions en format APK. Canvieu-ho per a inhabilitar les comprovacions automàtiques de GitHub.</string>
<string name="settings_support_summary">Uniu-vos a discussions de la comunitat i obteniu ajuda</string>
<string name="settings_support_title">Ajuda als usuaris</string>
<string name="settings_scan_result">S\'està analitzant: s\'han comptat %1$d pistes</string>
<string name="settings_image_size">Resolució de les imatges</string>
<string name="settings_language">Llengua</string>
<string name="settings_logout_title">Tanca la sessió</string>
<string name="settings_max_bitrate_download">Taxa de bits de les baixades</string>
<string name="settings_max_bitrate_mobile">Taxa de bits amb dades mòbils</string>
<string name="settings_max_bitrate_wifi">Taxa de bits amb wifi</string>
<string name="settings_media_cache">Mida de la memòria cau de fitxers multimèdia</string>
<string name="settings_music_directory">Mostra les carpetes de música</string>
<string name="settings_music_directory_summary">Si s\'habilita, es mostra la secció de carpetes de música. Tingueu en compte que, perquè la navegació amb carpetes funcioni correctament, el servidor ha de ser compatible amb aquesta característica.</string>
<string name="settings_podcast">Mostra els pòdcasts</string>
<string name="settings_podcast_summary">Si s\'habilita, es mostra la secció de pòdcasts. Reinicieu l\'aplicació perquè tingui efecte completament.</string>
<string name="settings_audio_quality">Mostra la qualitat de l\'àudio</string>
<string name="settings_audio_quality_summary">Es mostraran la taxa de bits i el format d\'àudio per a cada pista d\'àudio.</string>
<string name="settings_song_rating">Mostra la valoració amb estrelles de les cançons</string>
<string name="settings_song_rating_summary">Si s\'habilita, es mostra la valoració de 5 estrelles d\'una pista a la pàgina de la cançó.\n\n*Cal reiniciar l\'aplicació</string>
<string name="settings_item_rating">Mostra la valoració dels elements</string>
<string name="settings_item_rating_summary">Si s\'habilita, es mostrarà la valoració dels elements i si s\'han marcat com a preferits.</string>
<string name="settings_queue_syncing_countdown">Temporitzador de sincronització</string>
<string name="settings_queue_syncing_summary">Si s\'habilita, l\'usuari tindrà la possibilitat de desar la cua de reproducció i carregar l\'estat en obrir l\'aplicació.</string>
<string name="settings_queue_syncing_title">Sincronitza la cua de reproducció d\'aquest usuari (implantat parcialment)</string>
<string name="settings_show_mini_shuffle_button">Mostra el botó de reproducció aleatòria</string>
<string name="settings_show_mini_shuffle_button_summary">Si s\'habilita, es mostra el botó de reproducció aleatòria i se suprimeix el cor del minireproductor.</string>
<string name="settings_radio">Mostra la ràdio</string>
<string name="settings_radio_summary">Si s\'habilita, es mostra la secció de ràdio. Reinicieu l\'aplicació perquè tingui efecte completament.</string>
<string name="settings_auto_download_lyrics">Baixa les lletres automàticament</string>
<string name="settings_auto_download_lyrics_summary">Desa automàticament les lletres quan estiguin disponibles perquè es puguin mostrar fora de línia.</string>
<string name="settings_replay_gain">Mode de ReplayGain</string>
<string name="settings_rounded_corner">Cantonades arrodonides</string>
<string name="settings_rounded_corner_size">Mida de les cantonades</string>
<string name="settings_rounded_corner_size_summary">Defineix la magnitud de l\'angle de curvatura.</string>
<string name="settings_rounded_corner_summary">Si s\'habilita, defineix un angle de curvatura per a totes les caràtules representades. Els canvis tindran efecte quan reinicieu l\'aplicació.</string>
<string name="settings_scan_title">Analitza la biblioteca</string>
<string name="settings_scrobble_title">Habilita l\'anàlisi musical</string>
<string name="settings_system_language">Llengua del sistema</string>
<string name="settings_share_title">Habilita l\'ús compartit de música</string>
<string name="settings_streaming_cache_size">Mida de la memòria cau de transmissió</string>
<string name="settings_streaming_cache_storage_title">Emmagatzematge de la memòria cau de transmissió</string>
<string name="settings_sub_summary_scrobble">Tingueu en compte que l\'anàlisi musical també depèn que el servidor tingui habilitada la recepció d\'aquestes dades.</string>
<string name="settings_summary_skip_min_star_rating">En escoltar la ràdio d\'un artista, una mescla instantània o totes les cançons aleatòriament, les pistes per sota d\'un llindar de l\'usuari s\'ignoraran.</string>
<string name="settings_summary_replay_gain">ReplayGain és una característica que us permet ajustar el nivell de volum de les pistes d\'àudio perquè l\'escolta sigui coherent. Aquest paràmetre només és efectiu si la pista conté les metadades necessàries.</string>
<string name="settings_summary_scrobble">L\'anàlisi musical és una característica que permet al vostre dispositiu enviar informació sobre les cançons que escolteu al servidor de música. Aquesta informació ajuda a crear recomanacions personalitzades a partir de les vostres preferències musicals.</string>
<string name="settings_summary_share">Permet que l\'usuari comparteixi música mitjançant un enllaç. El servidor ha de ser compatible i tenir habilitada aquesta característica i es limita a pistes individuals, àlbums i llistes de reproducció.</string>
<string name="settings_summary_syncing">Retorna l\'estat de la cua de reproducció per a aquest usuari. Això inclou les pistes a la cua de reproducció, la pista en reproducció actualment i la posició de la pista. El servidor ha de ser compatible amb aquesta característica.\n*Aquest paràmetre no funciona al 100% en tots els servidors/dispositius.</string>
<string name="settings_summary_streaming_cache_size">%1$s \nEn ús actualment: %2$s MiB</string>
<string name="settings_summary_transcoding">Es dona prioritat al mode de transcodificació. Si s\'estableix en «Reproducció directa», la taxa de bits del fitxer no canviarà.</string>
<string name="settings_summary_transcoding_download">Baixa el contingut multimèdia transcodificat. Si s\'habilita, no es farà servir l\'extrem de baixada, sinó els paràmetres següents. \n\nSi s\'estableix «Format de transcodificació per a les baixades» en «Baixada directa», la taxa de bits del fitxer no canviarà.</string>
<string name="settings_summary_transcoding_estimate_content_length">Quan el fitxer es transcodifica en temps real, el client normalment no mostra la durada de la pista. És possible sol·licitar una estimació de la durada de la pista en reproducció als servidors compatibles, però els temps de resposta poden ser més llargs.</string>
<string name="settings_sync_starred_artists_for_offline_use_summary">Si s\'habilita, els artistes amb estrelles es baixaran per a l\'ús fora de línia.</string>
<string name="settings_sync_starred_artists_for_offline_use_title">Sincronitza els artistes amb estrelles per a l\'ús fora de línia</string>
<string name="settings_sync_starred_albums_for_offline_use_summary">Si s\'habilita, els àlbums amb estrelles es baixaran per a l\'ús fora de línia.</string>
<string name="settings_sync_starred_albums_for_offline_use_title">Sincronitza els àlbums amb estrelles per a l\'ús fora de línia</string>
<string name="settings_sync_starred_tracks_for_offline_use_summary">Si s\'habilita, les pistes amb estrelles es baixaran per a l\'ús fora de línia.</string>
<string name="settings_sync_starred_tracks_for_offline_use_title">Sincronitza les pistes amb estrelles per a l\'ús fora de línia</string>
<string name="settings_theme">Tema</string>
<string name="settings_title_data">Dades</string>
<string name="settings_title_general">General</string>
<string name="settings_title_playlist">Llista de reproducció</string>
<string name="settings_title_rating">Valoració</string>
<string name="settings_title_replay_gain">ReplayGain</string>
<string name="settings_title_scrobble">Anàlisi musical</string>
<string name="settings_title_skip_min_star_rating">Ignora les pistes segons la valoració</string>
<string name="settings_title_skip_min_star_rating_dialog">Cançons amb una valoració de:</string>
<string name="settings_title_share">Comparteix</string>
<string name="settings_title_syncing">Sincronització</string>
<string name="settings_title_transcoding">Transcodificació</string>
<string name="settings_title_transcoding_download">Baixada transcodificada</string>
<string name="settings_title_ui">Interfície d\'usuari</string>
<string name="settings_transcoded_download">Baixada transcodificada</string>
<string name="settings_version_summary" translatable="false">3.1.0</string>
<string name="settings_version_title">Versió</string>
<string name="settings_wifi_only_summary">Demana la confirmació de l\'usuari abans d\'iniciar la transmissió per la xarxa mòbil.</string>
<string name="settings_wifi_only_title">Alerta de transmissió només per wifi</string>
<string name="share_bottom_sheet_copy_link">Copia l\'enllaç</string>
<string name="share_bottom_sheet_delete">Suprimeix l\'element compartit</string>
<string name="share_bottom_sheet_update">Actualitza l\'element compartit</string>
<string name="share_subtitle_item">Data de venciment: %1$s</string>
<string name="share_no_expiration">Mai</string>
<string name="share_unsupported_error">L\'ús compartit no s\'admet o no està habilitat</string>
<string name="asset_link_clipboard_label">Enllaç a recurs de Tempus</string>
<string name="asset_link_label_song">UID de la cançó</string>
<string name="asset_link_label_album">UID de l\'àlbum</string>
<string name="asset_link_label_artist">UID de l\'artista</string>
<string name="asset_link_label_playlist">UID de la llista de reproducció</string>
<string name="asset_link_label_genre">UID del gènere</string>
<string name="asset_link_label_year">UID de l\'any</string>
<string name="asset_link_label_unknown">UID del recurs</string>
<string name="asset_link_error_unsupported">Enllaç a recurs no admès</string>
<string name="asset_link_error_song">No s\'ha pogut obrir la cançó</string>
<string name="asset_link_error_album">No s\'ha pogut obrir l\'àlbum</string>
<string name="asset_link_error_artist">No s\'ha pogut obrir l\'artista</string>
<string name="asset_link_error_playlist">No s\'ha pogut obrir la llista de reproducció</string>
<string name="asset_link_chip_text">%1$s • %2$s</string>
<string name="asset_link_copied_toast">S\'ha copiat %1$s al porta-retalls</string>
<string name="asset_link_debug_toast">Enllaç al recurs: %1$s</string>
<string name="share_update_dialog_hint_description">Descripció</string>
<string name="share_update_dialog_hint_expiration_date">Data de venciment</string>
<string name="share_update_dialog_negative_button">Cancel·la</string>
<string name="share_update_dialog_positive_button">Desa</string>
<string name="share_update_dialog_title">Comparteix</string>
<string name="song_bottom_sheet_add_to_playlist">Afegeix a la llista de reproducció</string>
<string name="song_bottom_sheet_add_to_queue">Afegeix a la cua</string>
<string name="song_bottom_sheet_download">Baixa</string>
<string name="song_bottom_sheet_error_retrieving_album">S\'ha produït un error en recuperar l\'àlbum</string>
<string name="song_bottom_sheet_error_retrieving_artist">S\'ha produït un error en recuperar l\'artista</string>
<string name="song_bottom_sheet_go_to_album">Ves a l\'àlbum</string>
<string name="song_bottom_sheet_go_to_artist">Ves a l\'artista</string>
<string name="song_bottom_sheet_instant_mix">Mescla instantània</string>
<string name="song_bottom_sheet_play_next">Reprodueix a continuació</string>
<string name="song_bottom_sheet_rate">Valoració</string>
<string name="song_bottom_sheet_remove">Suprimeix</string>
<string name="song_bottom_sheet_share">Comparteix</string>
<string name="song_list_page_downloaded">Baixades</string>
<string name="song_list_page_most_played">Pistes més reproduïdes</string>
<string name="song_list_page_recently_added">Pistes afegides recentment</string>
<string name="song_list_page_recently_played">Pistes reproduïdes recentment</string>
<string name="song_list_page_starred">Pistes amb estrelles</string>
<string name="song_list_page_top">Les millors cançons de %1$s</string>
<string name="song_list_page_year">Any %1$d</string>
<string name="song_subtitle_formatter">%1$s • %2$s %3$s</string>
<string name="starred_sync_dialog_negative_button">Cancel·la</string>
<string name="starred_sync_dialog_neutral_button">Continua</string>
<string name="starred_sync_dialog_positive_button">Continua i baixa</string>
<string name="starred_sync_dialog_summary">La baixada de les pistes amb estrelles pot requerir un ús de dades important.</string>
<string name="starred_sync_dialog_title">Sincronitza les pistes amb estrelles</string>
<string name="starred_artist_sync_dialog_summary">La baixada dels artistes amb estrelles pot requerir un ús de dades important.</string>
<string name="starred_artist_sync_dialog_title">Sincronitza els artistes amb estrelles</string>
<string name="starred_album_sync_dialog_summary">La baixada dels àlbums amb estrelles pot requerir un ús de dades important.</string>
<string name="starred_album_sync_dialog_title">Sincronitza els àlbums amb estrelles</string>
<string name="streaming_cache_storage_dialog_sub_summary">Perquè els canvis tinguin efecte, reinicieu l\'aplicació.</string>
<string name="streaming_cache_storage_dialog_summary">Si canvieu la destinació dels fitxers emmagatzemats a la memòria cau d\'un emmagatzematge a un altre, se suprimiran immediatament tots els fitxers emmagatzemats anteriorment a la memòria cau de l\'altre emmagatzematge.</string>
<string name="streaming_cache_storage_dialog_title">Selecció de l\'opció d\'emmagatzematge</string>
<string name="streaming_cache_storage_external_dialog_positive_button">Extern</string>
<string name="streaming_cache_storage_internal_dialog_negative_button">Intern</string>
<string name="support_url">https://ko-fi.com/eddyizm</string>
<string name="track_info_album">Àlbum</string>
<string name="track_info_artist">Artista</string>
<string name="track_info_bit_depth">Profunditat de bits</string>
<string name="track_info_bitrate">Taxa de bits</string>
<string name="track_info_content_type">Tipus de contingut</string>
<string name="track_info_dialog_positive_button">D\'acord</string>
<string name="track_info_dialog_title">Informació de la pista</string>
<string name="track_info_disc_number">Número de disc</string>
<string name="track_info_duration">Durada</string>
<string name="track_info_genre">Gènere</string>
<string name="track_info_path">Camí</string>
<string name="track_info_sampling_rate">Freqüència de mostreig</string>
<string name="track_info_size">Mida</string>
<string name="track_info_suffix">Sufix</string>
<string name="track_info_summary_downloaded_file">El fitxer s\'ha baixat amb les API del Subsonic. El còdec i la taxa de bits del fitxer són els mateixos que els del fitxer d\'origen.</string>
<string name="track_info_summary_full_transcode">L\'aplicació sol·licitarà al servidor la transcodificació del fitxer i la modificació de la taxa de bits. El còdec sol·licitat per l\'usuari és %1$s, amb una taxa de bits de %2$s. Qualsevol canvi potencial del còdec i la taxa de bits del fitxer en el format triat el gestionarà el servidor, que pot admetre l\'operació o no.</string>
<string name="track_info_summary_original_file">L\'aplicació només llegirà el fitxer original proporcionat pel servidor. L\'aplicació sol·licitarà explícitament al servidor el fitxer transcodificat amb la taxa de bits del fitxer original.</string>
<string name="track_info_summary_server_prioritized">La qualitat del fitxer reproduït dependrà de la decisió del servidor. L\'aplicació no forçarà la tria de còdec i taxa de bits per a cap transcodificació potencial.</string>
<string name="track_info_summary_transcoding_bitrate">L\'aplicació sol·licitarà al servidor la modificació de la taxa de bits del fitxer. L\'usuari ha sol·licitat una taxa de bits de %1$s, mentre que el còdec del fitxer d\'origen seguirà sent el mateix. Qualsevol canvi de la taxa de bits del fitxer en el format triat el gestionarà el servidor, que pot admetre l\'operació o no.</string>
<string name="track_info_summary_transcoding_codec">L\'aplicació sol·licitarà al servidor la transcodificació del fitxer. El còdec sol·licitat per l\'usuari és %1$s, mentre que la taxa de bits serà la mateixa que la del fitxer d\'origen. La transcodificació potencial del fitxer en el format triat depèn del servidor, que pot admetre l\'operació o no.</string>
<string name="track_info_title">Títol</string>
<string name="track_info_track_number">Número de pista</string>
<string name="track_info_transcoded_content_type">Tipus de contingut transcodificat</string>
<string name="track_info_transcoded_suffix">Sufix de la transcodificació</string>
<string name="track_info_year">Any</string>
<string name="undraw_page">unDraw</string>
<string name="undraw_thanks">Volem donar un agraïment especial a UnDraw; aquesta aplicació no seria tan bonica sense les seves il·lustracions.</string>
<string name="undraw_url">https://undraw.co/</string>
<string name="widget_label">Giny de Tempus</string>
<string name="widget_not_playing">No hi ha res en reproducció</string>
<string name="widget_placeholder_subtitle">Obre Tempus</string>
<string name="widget_time_elapsed_placeholder">0:00</string>
<string name="widget_time_duration_placeholder">0:00</string>
<string name="widget_content_desc_album_art">Caràtula de l\'àlbum</string>
<string name="widget_content_desc_play_pause">Reprodueix o posa en pausa</string>
<string name="widget_content_desc_next">Pista següent</string>
<string name="widget_content_desc_prev">Pista anterior</string>
<string name="widget_content_desc_shuffle">Reprodueix aleatòriament</string>
<string name="widget_content_desc_repeat">Canvia el mode de repetició</string>
<plurals name="home_sync_starred_albums_count">
<item quantity="one">Se sincronitzarà %d àlbum</item>
<item quantity="other">Se sincronitzaran %d àlbums</item>
</plurals>
<plurals name="home_sync_starred_artists_count">
<item quantity="one">Se sincronitzarà %d artista</item>
<item quantity="other">Se sincronitzaran %d artistes</item>
</plurals>
<plurals name="songs_download_started">
<item quantity="one">S\'està baixant %d cançó</item>
<item quantity="other">S\'estan baixant %d cançons</item>
</plurals>
<string name="equalizer_fragment_title">Equalitzador</string>
<string name="equalizer_reset">Reinicialitza</string>
<string name="equalizer_enable">Habilita</string>
<string name="equalizer_not_supported">No és compatible amb aquest dispositiu</string>
<string name="settings_app_equalizer">Equalitzador</string>
<string name="settings_app_equalizer_summary">Obre l\'equalitzador integrat</string>
<string name="settings_album_detail">Mostra els detalls de l\'àlbum</string>
<string name="settings_album_detail_summary">Si s\'habilita, es mostren els detalls de l\'àlbum, com el gènere i el nombre de cançons, a la pàgina de l\'àlbum.</string>
<string name="settings_artist_sort_by_album_count">Ordena els artistes per nombre d\'àlbums</string>
<string name="settings_artist_sort_by_album_count_summary">Si s\'habilita, ordena els artistes per nombre d\'àlbums. Si no, s\'ordenen per nom.</string>
</resources>

View File

@@ -240,6 +240,15 @@
<item>8</item>
</string-array>
<string-array name="playlist_sort_option_titles">
<item>Por nombre</item>
<item>Aleatoriamente</item>
</string-array>
<string-array name="playlist_sort_option_values">
<item>ORDER_BY_NAME</item>
<item>ORDER_BY_RANDOM</item>
</string-array>
<string-array name="skip_min_star_rating_titles">
<item>0 estrellas como mínimo</item>
<item>1 estrella como mínimo</item>

View File

@@ -3,7 +3,7 @@
<string name="album_page_tracks_count_and_duration">%1$d pistas • %2$d minutos</string>
<string name="app_name">Tempus</string>
<string name="activity_battery_optimizations_conclusion">Si tienes problemas, visita https://dontkillmyapp.com. Ofrece instrucciones detalladas para desactivar características de ahorro de energía que podrían afectar al rendimiento de la app.</string>
<string name="activity_battery_optimizations_summary">Por favor, desactive las optimizaciones de batería para continuar la reproducción multimedia mientras la pantalla está apagada.</string>
<string name="activity_battery_optimizations_summary">Por favor, desactiva las optimizaciones de batería para continuar la reproducción multimedia mientras la pantalla está apagada.</string>
<string name="activity_battery_optimizations_title">Optimizaciones de batería</string>
<string name="activity_info_offline_mode">Modo sin conexión</string>
<string name="album_bottom_sheet_add_to_playlist">Añadir a la lista de reproducción</string>
@@ -40,6 +40,7 @@
<string name="artist_list_page_downloaded">Artistas descargados</string>
<string name="artist_list_page_starred">Artistas destacados</string>
<string name="artist_list_page_title">Artistas</string>
<string name="artist_no_artist_info_toast">No hay más información del artista</string>
<string name="artist_page_radio_button">Radio</string>
<string name="artist_page_shuffle_button">Aleatorio</string>
<string name="artist_page_switch_layout_button">Cambiar disposición</string>
@@ -52,6 +53,7 @@
<string name="battery_optimization_negative_button">Ignorar</string>
<string name="battery_optimization_neutral_button">No volver a preguntar</string>
<string name="battery_optimization_positive_button">Desactivar</string>
<string name="bottom_sheet_problem_generating_instant_mix">No se pudieron obtener las pistas del servidor.</string>
<string name="connection_alert_dialog_negative_button">Cancelar</string>
<string name="connection_alert_dialog_neutral_button">Habilitar el ahorro de datos</string>
<string name="connection_alert_dialog_positive_button">Aceptar</string>
@@ -60,9 +62,9 @@
<string name="content_description_shuffle_button">Aleatorio</string>
<string name="delete_download_storage_dialog_negative_button">Cancelar</string>
<string name="delete_download_storage_dialog_positive_button">Continuar</string>
<string name="delete_download_storage_dialog_summary">Por favor, sea consciente de que si continúa, todos los elementos descargados de todos los servidores se eliminarán.</string>
<string name="delete_download_storage_dialog_summary">Por favor, ten en cuenta que si continúas, todos los elementos descargados de todos los servidores se eliminarán.</string>
<string name="delete_download_storage_dialog_title">Eliminar elementos guardados</string>
<string name="description_empty_title">Descripción no disponible</string>
<string name="description_empty_title">Letra no disponible</string>
<string name="disc_titlefull">Disco %1$s - %2$s</string>
<string name="disc_titleless">Disco %1$s</string>
<string name="download_directory_dialog_negative_button">Cancelar</string>
@@ -111,12 +113,12 @@
<string name="home_rearrangement_dialog_neutral_button">Restablecer</string>
<string name="home_rearrangement_dialog_positive_button">Guardar</string>
<string name="home_rearrangement_dialog_title">Reorganizar la página de inicio</string>
<string name="home_rearrangement_dialog_subtitle">Tenga en cuenta que para que los cambios surtan efecto, hay que reiniciar la aplicación.</string>
<string name="home_rearrangement_dialog_subtitle">Ten en cuenta que para que los cambios surtan efecto, hay que reiniciar la aplicación.</string>
<string name="home_section_music">Música</string>
<string name="home_section_podcast">Pódcasts</string>
<string name="home_section_radio">Radio</string>
<string name="home_subtitle_best_of">Mejores pistas de tus artistas favoritos</string>
<string name="home_subtitle_made_for_you">Iniciar mix desde una cación que te gustó</string>
<string name="home_subtitle_made_for_you">Iniciar mix desde una canción que te gustó</string>
<string name="home_subtitle_new_internet_radio_station">Añadir una nueva emisora de radio</string>
<string name="home_subtitle_new_podcast_channel">Añadir un nuevo canal de pódcasts</string>
<string name="home_sync_starred_cancel">Cancelar</string>
@@ -177,6 +179,7 @@
<string name="menu_filter_download">Descargado</string>
<string name="menu_group_by_album">Álbum</string>
<string name="menu_group_by_artist">Artista</string>
<string name="settings_github_update_title">Comprobar actualizaciones en GitHub</string>
<string name="settings_scan_result">Escaneo: hay %1$d pistas</string>
<string name="settings_support_title">Soporte al usuario</string>
<string name="settings_image_size">Resolución de la imagen</string>
@@ -185,7 +188,7 @@
<string name="settings_logout_title">Cerrar sesión</string>
<string name="settings_github_link">https://github.com/eddyizm/tempus</string>
<string name="settings_github_summary">Siga el desarrollo</string>
<string name="settings_github_title">Github</string>
<string name="settings_github_title">GitHub</string>
<string name="menu_group_by_genre">Género</string>
<string name="menu_group_by_track">Pista</string>
<string name="menu_group_by_year">Año</string>
@@ -199,6 +202,7 @@
<string name="menu_sort_artist">Artista</string>
<string name="menu_sort_name">Nombre</string>
<string name="menu_sort_random">Aleatorio</string>
<string name="menu_sort_album_count">Número de álbumes</string>
<string name="menu_sort_recently_added">Añadido recientemente</string>
<string name="menu_sort_recently_played">Reproducido recientemente</string>
<string name="menu_sort_most_played">Lo más reproducido</string>
@@ -223,6 +227,8 @@
<string name="playlist_chooser_dialog_title">Añadir a una lista de reproducción</string>
<string name="playlist_chooser_dialog_toast_add_failure">Error al añadir a la lista</string>
<string name="playlist_chooser_dialog_toast_all_skipped">Todas las pistas se han descartado porque están repetidas</string>
<string name="playlist_chooser_dialog_visibility_public">Público</string>
<string name="playlist_chooser_dialog_visibility_private">Privado</string>
<string name="playlist_counted_tracks">%1$d pistas • %2$s</string>
<string name="playlist_duration">Duración • %1$s</string>
<string name="playlist_editor_dialog_action_delete_toast">Pulsación larga para eliminar</string>
@@ -244,6 +250,7 @@
<string name="podcast_channel_catalogue_title_expanded">Explorar canales</string>
<string name="podcast_channel_editor_dialog_hint_rss_url">URL del feed RSS</string>
<string name="podcast_channel_editor_dialog_title">Canal del pódcast</string>
<string name="podcast_channel_not_supported_snackbar">Este servidor no soporta pódcasts.</string>
<string name="podcast_channel_page_title_description_section">Descripción</string>
<string name="podcast_channel_page_title_episode_section">Episodios</string>
<string name="podcast_channel_page_title_no_episode_available">No hay episodios disponibles</string>
@@ -258,10 +265,12 @@
<string name="radio_editor_dialog_negative_button">Cancelar</string>
<string name="radio_editor_dialog_neutral_button">Eliminar</string>
<string name="radio_editor_dialog_positive_button">Guardar</string>
<string name="radio_editor_dialog_title">"Emisora "</string>
<string name="radio_editor_dialog_updated">Emisora de radio actualizada</string>
<string name="radio_editor_dialog_title">Emisora</string>
<string name="radio_station_info_empty_button">Pulsa para ocultar la sección\nLos cambios serán visibles al reiniciar la app</string>
<string name="radio_station_info_empty_subtitle">Una vez que añadas una emisora de radio, la encontrarás aquí</string>
<string name="radio_station_info_empty_title">No hay emisoras de radio</string>
<string name="radio_dialog_not_supported_snackbar">Este servidor no soporta emisoras de radio en Internet.</string>
<string name="rating_dialog_negative_button">Cancelar</string>
<string name="rating_dialog_positive_button">Guardar</string>
<string name="rating_dialog_title">Valorar</string>
@@ -287,34 +296,34 @@
<string name="server_unreachable_dialog_negative_button">Cancelar</string>
<string name="server_unreachable_dialog_neutral_button">Ir al inicio de sesión</string>
<string name="server_unreachable_dialog_positive_button">Continuar de todas formas</string>
<string name="server_unreachable_dialog_summary">El servidor no está disponible. Si decide continuar, este diálogo no aparecerá de nuevo durante una hora.</string>
<string name="server_unreachable_dialog_summary">El servidor no está disponible. Si decides continuar, este diálogo no aparecerá de nuevo durante una hora.</string>
<string name="server_unreachable_dialog_title">No se puede conectar con el servidor</string>
<string name="settings_about_summary">Tempus es un cliente de música Subsonic ligero y de código abierto, diseñado nativamente para Android.</string>
<string name="settings_about_title">Acerca de</string>
<string name="settings_always_on_display">Pantalla siempre activa</string>
<string name="settings_allow_playlist_duplicates_summary">Si está habilitada, no se comprobará si hay pistas repetidas cuando se añadan a la lista.</string>
<string name="settings_allow_playlist_duplicates_summary">Si se habilita, no se comprobará si hay pistas repetidas cuando se añadan a la lista.</string>
<string name="settings_audio_transcode_download_format">Formato de transcodificación</string>
<string name="settings_audio_transcode_download_priority_summary">Si está habilitada, Tempus no descargará la pista con las opciones de transcodificación que aparecen a continuación.</string>
<string name="settings_audio_transcode_download_priority_summary">Si se habilita, Tempus no descargará la pista con las opciones de transcodificación que aparecen a continuación.</string>
<string name="settings_audio_transcode_download_priority_title">Dar prioridad a las opciones del servidor usadas para el streaming en las descargas</string>
<string name="settings_audio_transcode_download_summary">Si está habilitada, Tempus descargará las pistas transcodificadas.</string>
<string name="settings_audio_transcode_download_summary">Si se habilita, Tempus descargará las pistas transcodificadas.</string>
<string name="settings_audio_transcode_download_title">Descargas pistas transcodificadas</string>
<string name="settings_audio_transcode_estimate_content_length_summary">Si está habilitada, se pedirá al servidor la duración estimada de la pista.</string>
<string name="settings_audio_transcode_estimate_content_length_summary">Si se habilita, se pedirá al servidor la duración estimada de la pista.</string>
<string name="settings_audio_transcode_estimate_content_length_title">Calcular la duración del contenido</string>
<string name="settings_audio_transcode_format_download">Formato de transcodificación para las descargas</string>
<string name="settings_audio_transcode_format_mobile">Formato de transcodificación en red de datos móviles</string>
<string name="settings_audio_transcode_format_wifi">Formato de transcodificación en red Wi-Fi</string>
<string name="settings_audio_transcode_priority_summary">Si está habilitada, Tempus no reproducirá la pista con las opciones de transcodificación que aparecen a continuación.</string>
<string name="settings_audio_transcode_priority_summary">Si se habilita, Tempus no reproducirá la pista con las opciones de transcodificación que aparecen a continuación.</string>
<string name="settings_audio_transcode_priority_title">Dar prioridad a las opciones de transcodificación del servidor</string>
<string name="settings_audio_transcode_priority_toast">Prioridad a la hora de transcodificar una pista</string>
<string name="settings_buffering_strategy">Estrategia de buffer</string>
<string name="settings_buffering_strategy_summary">Para que los cambios surtan efecto, debes reinciar la app.</string>
<string name="settings_buffering_strategy_summary">Para que los cambios surtan efecto, debes reiniciar la app.</string>
<string name="settings_choose_download_folder">Elige una carpeta para descargar los archivos de música</string>
<string name="settings_clear_download_folder">Limpiar la carpeta de descargas</string>
<string name="settings_continuous_play_summary">Permite que la música siga reproduciéndose una vez que la lista de reproducción ha terminado, reproduciendo pistas similares</string>
<string name="settings_continuous_play_title">Reproducción continua</string>
<string name="settings_covers_cache">Tamaño de la caché de portadas de álbumes</string>
<string name="settings_data_saving_mode_summary">Evitar descargar las portadas de álbumes para reducir el uso de datos</string>
<string name="settings_data_saving_mode_title">Limitr el uso de datos móviles</string>
<string name="settings_data_saving_mode_title">Limitar el uso de datos móviles</string>
<string name="settings_delete_download_storage_summary">Al continuar se eliminarán de forma irreversible todos los elementos guardados.</string>
<string name="settings_delete_download_storage_title">Eliminar elementos guardados</string>
<string name="settings_download_storage_title">Almacenamiento de descargas</string>
@@ -323,21 +332,22 @@
<string name="settings_max_bitrate_wifi">Tasa de bits en Wi-Fi</string>
<string name="settings_media_cache">Tamaño de la caché multimedia</string>
<string name="settings_music_directory">Mostrar carpetas de música</string>
<string name="settings_music_directory_summary">Si está habilitada, se mostrará la sección de carpetas de música. Tenga en cuenta que para que la navegación funcione correctamente, el servidor debe soportar esta característica.</string>
<string name="settings_music_directory_summary">Si se habilita, se mostrará la sección de carpetas de música. Tenga en cuenta que para que la navegación funcione correctamente, el servidor debe soportar esta característica.</string>
<string name="settings_podcast">Mostrar pódcasts</string>
<string name="settings_podcast_summary">Si está habilitada, se mostrará la sección de pódcasts. Reinicia la aplicación para que los cambios surtan efecto.</string>
<string name="settings_podcast_summary">Si se habilita, se mostrará la sección de pódcasts. Reinicia la aplicación para que los cambios surtan efecto.</string>
<string name="settings_playlist_sort">Ordenar listas de reproducción</string>
<string name="settings_audio_quality">Mostrar calidad de audio</string>
<string name="settings_audio_quality_summary">La tasa de bits y el formato de audio se mostrarán para cada pista de audio.</string>
<string name="settings_song_rating_summary">Si está habilitada, muestra la valoración de la pista como barra de 5 estrellas en la página del control de reproducción.\n\n*Requiere reiniciar la aplicación</string>
<string name="settings_song_rating_summary">Si se habilita, muestra la valoración de la pista como barra de 5 estrellas en la página del control de reproducción.\n\n*Requiere reiniciar la aplicación</string>
<string name="settings_item_rating">Mostrar valoración de los elementos</string>
<string name="settings_queue_syncing_title">Sincronizar cola de reproducción para este usuario</string>
<string name="settings_show_mini_shuffle_button_summary">Si está habilitada, muestra el botón de reproducción aleatoria y oculta el botón de «Favoritos» en el minirreproductor</string>
<string name="settings_show_mini_shuffle_button_summary">Si se habilita, muestra el botón de reproducción aleatoria y oculta el botón de «Favoritos» en el minirreproductor.</string>
<string name="settings_radio">Mostrar emisoras de radio</string>
<string name="settings_auto_download_lyrics_summary">Descargar las letras automáticamente cuando estén disponibles para que se puedan mostrar cuando no hay conexión.</string>
<string name="settings_replay_gain">Configurar el modo de ganancia de reproducción</string>
<string name="settings_rounded_corner">Esquinas redondeadas</string>
<string name="settings_rounded_corner_size">Tamaño de las esquinas</string>
<string name="settings_rounded_corner_summary">Si está habilitada, establece un ángulo de curvatura para todas las portadas de álbumes. Los cambios se aplicarán después de reiniciar la app.</string>
<string name="settings_rounded_corner_summary">Si se habilita, establece un ángulo de curvatura para todas las portadas de álbumes. Los cambios se aplicarán después de reiniciar la app.</string>
<string name="settings_scan_title">Escanear biblioteca</string>
<string name="streaming_cache_storage_dialog_summary">Cambiar la ubicación de los archivos en caché a otro almacenamiento puede causar el borrado de todos los archivos en caché en el anterior almacenamiento.</string>
<string name="streaming_cache_storage_dialog_title">Seleccióna un tipo de almacenamiento</string>
@@ -366,10 +376,10 @@
<string name="settings_summary_streaming_cache_size">%1$s\nEn uso: %2$s MiB</string>
<string name="undraw_url">https://undraw.co/</string>
<string name="track_info_track_number">Número de pista</string>
<string name="settings_item_rating_summary">Si está habilitada, se mostrará la valoración del elemento y si está marcado como favorito.</string>
<string name="settings_item_rating_summary">Si se habilita, se mostrará la valoración del elemento y si está marcado como favorito.</string>
<string name="settings_queue_syncing_countdown">Temporizador de sincronización</string>
<string name="settings_queue_syncing_summary">Si está habilitada, el usuario podrá guardar la cola de reproducción y restaurarla cuando abra la aplicación.</string>
<string name="settings_radio_summary">Si está habilitada, se mostrará la sección de emisoras de radio. Reinicia la app para que los cambios surtan efecto.</string>
<string name="settings_queue_syncing_summary">Si se habilita, el usuario podrá guardar la cola de reproducción y restaurarla cuando abra la aplicación.</string>
<string name="settings_radio_summary">Si se habilita, se mostrará la sección de emisoras de radio. Reinicia la app para que los cambios surtan efecto.</string>
<string name="settings_rounded_corner_size_summary">Establece la proporción del ángulo de curvatura.</string>
<string name="track_info_dialog_title">Información de la pista</string>
<string name="track_info_disc_number">Número de disco</string>
@@ -413,7 +423,9 @@
<string name="share_bottom_sheet_delete">Eliminar compartición</string>
<string name="share_bottom_sheet_update">Actualizar compartición</string>
<string name="share_subtitle_item">Fecha de caducidad: %1$s</string>
<string name="share_no_expiration">Nunca</string>
<string name="share_unsupported_error">El uso compartido no está soportado o no está habilitado</string>
<string name="asset_link_debug_toast">Enlace de recurso: %1$s</string>
<string name="share_update_dialog_hint_description">Descripción</string>
<string name="share_update_dialog_hint_expiration_date">Fecha de caducidad</string>
<string name="song_bottom_sheet_add_to_queue">Añadir a la cola</string>
@@ -439,10 +451,10 @@
<string name="starred_sync_dialog_title">Sincronizar las pistas destacadas</string>
<string name="starred_album_sync_dialog_title">Sincronizar álbumes favoritos</string>
<string name="streaming_cache_storage_dialog_sub_summary">Para que los cambios tengan efecto, reinicia la app.</string>
<string name="settings_summary_transcoding_download">Descarga los archivos multimedia transcodificados. Si esta opción está habilitada, no se usará el endpoint de descarga, sino las siguientes opciones.\n\nSi el formato de transcodificación para las descargas se establece en \"Descarga directa\", no se modificará la tasa de bits del archivo.</string>
<string name="settings_summary_transcoding_download">Descarga los archivos multimedia transcodificados. Si se habilita, no se usará el endpoint de descarga, sino las siguientes opciones.\n\nSi el formato de transcodificación para las descargas se establece en \"Descarga directa\", no se modificará la tasa de bits del archivo.</string>
<string name="settings_summary_transcoding_estimate_content_length">Cuando el archivo se transcodifica en tiempo real, el cliente normalmente no muestra la duración de la pista. Es posible solicitar a los servidores que soporten esta característica, que calculen la duración de la pista que se está reproduciendo, pero los tiempos de respuesta podrían aumentar.</string>
<string name="settings_sync_starred_albums_for_offline_use_title">Sincronizar álbumes favoritos para uso sin conexión</string>
<string name="settings_sync_starred_tracks_for_offline_use_summary">Si está habilitada, las pistas destacadas se descargarán para uso sin conexión.</string>
<string name="settings_sync_starred_tracks_for_offline_use_summary">Si se habilita, las pistas destacadas se descargarán para uso sin conexión.</string>
<string name="track_info_summary_downloaded_file">El archivo se ha descargado usando las APIs de Subsonic. El códec y la tasa de bits del archivo se mantienen sin cambios respecto al archivo de origen.</string>
<string name="track_info_summary_full_transcode">La aplicación pedirá al servidor transcodificar el archivo y modificar su tasa de bits. El códec pedido por el usuario es %1$s, con una tasa de bits de %2$s. Cualquier cambio en el códec y tasa de bits del archivo en el formato elegido será manejado por el servidor, que puede, o no, soportar esta operación.</string>
<string name="track_info_summary_original_file">La aplicación solo leerá el archivo original ofrecido por el servidor. La app pedirá al servidor, de forma explícita, el archivo sin transcodificar con la tasa de bits de origen.</string>
@@ -452,7 +464,7 @@
<string name="settings_song_rating">Mostrar valoración de las pistas</string>
<string name="home_sync_starred_albums_title">Sincronizar álbumes favoritos</string>
<string name="settings_sync_starred_artists_for_offline_use_title">Sincronizar artistas destacados para uso sin conexión</string>
<string name="settings_sync_starred_albums_for_offline_use_summary">Si está habilitada, los álbumes favoritos se descargarán para uso sin conexión.</string>
<string name="settings_sync_starred_albums_for_offline_use_summary">Si se habilita, los álbumes favoritos se descargarán para uso sin conexión.</string>
<string name="starred_artist_sync_dialog_title">Sincronizar artistas destacados</string>
<string name="starred_album_sync_dialog_summary">Descargar los álbumes favoritos puede consumir una gran cantidad de datos.</string>
<string name="equalizer_fragment_title">Ecualizador</string>
@@ -475,15 +487,45 @@
<string name="widget_content_desc_prev">Pista anterior</string>
<string name="download_refresh_no_directory">Establece una carpeta de descarga para actualizar tus descargas</string>
<string name="home_sync_starred_artists_title">Sincronizar artistas destacados</string>
<string name="player_queue_load_queue">Cargar cola de reproducción</string>
<string name="player_lyrics_download_content_description">Descargar letras para uso sin conexión</string>
<string name="player_lyrics_downloaded_content_description">Letras descargadas para uso sin conexión</string>
<string name="player_lyrics_download_success">Letra guardada para uso sin conexión</string>
<string name="settings_allow_playlist_duplicates">Permitir añadir pistas repetidas a la lista</string>
<string name="settings_github_update_summary">Si se usa la versión de GitHub, la app comprobará nuevas actualizaciones del APK.</string>
<string name="settings_support_summary">Participa en las discusiones y el soporte de la comunidad</string>
<string name="settings_show_mini_shuffle_button">Mostrar el botón «Aleatorio»</string>
<string name="settings_auto_download_lyrics">Descargar automáticamente las letras</string>
<string name="starred_artist_sync_dialog_summary">Descargar los artistas destacados podría consumir una gran cantidad de datos.</string>
<string name="settings_sync_starred_artists_for_offline_use_summary">Si está habilitada, los artistas destacados se descargarán para uso sin conexión.</string>
<string name="settings_sync_starred_artists_for_offline_use_summary">Si se habilita, los artistas destacados se descargarán para uso sin conexión.</string>
<string name="widget_time_elapsed_placeholder">0:00</string>
<string name="exo_controls_heart_off_description">Eliminar de favoritos</string>
<string name="asset_link_chip_text">%1$s • %2$s</string>
<string name="asset_link_copied_toast">Copiado %1$s al portapapeles</string>
<string name="settings_album_detail">Mostrar los detalles del álbum</string>
<string name="settings_album_detail_summary">Si se habilita, muestra los detalles del álbum, como el género, el número de pistas, etc. en la página de álbum</string>
<string name="asset_link_clipboard_label">Enlace de recurso de Tempus</string>
<string name="asset_link_label_song">UID de la pista</string>
<string name="asset_link_label_album">UID del álbum</string>
<string name="asset_link_label_artist">UID del artista</string>
<string name="asset_link_label_playlist">UID de la lista de reproducción</string>
<string name="asset_link_label_genre">UID del género</string>
<string name="asset_link_label_year">UID del año</string>
<string name="asset_link_label_unknown">UID del recurso</string>
<string name="asset_link_error_unsupported">Enlace de recurso no válido</string>
<string name="asset_link_error_song">No se ha podido abrir la pista</string>
<string name="asset_link_error_album">No se ha podido abrir el álbum</string>
<string name="asset_link_error_artist">No se ha podido abrir el artista</string>
<string name="asset_link_error_playlist">No se ha podido abrir la lista de reproducción</string>
<string name="settings_github_update">Actualizaciones</string>
<string name="bottom_sheet_generating_instant_mix">Generando mix instantáneo…</string>
<string name="player_queue_save_to_playlist">Guardar cola en una lista de reproducción</string>
<string name="radio_editor_dialog_added">Emisora de radio añadida</string>
<string name="settings_artist_sort_by_album_count">Ordenar artistas por número de álbumes</string>
<string name="settings_artist_sort_by_album_count_summary">Si se habilita, ordena los artistas por número de álbumes. En caso contrario, se ordenan por nombre.</string>
<string name="folder_play_collecting">Obteniendo pistas de la carpeta…</string>
<string name="folder_play_playing">Reproduciendo %d pistas</string>
<string name="folder_play_no_songs">No se encontraron pistas en la carpeta</string>
<string name="search_sort_title">Ordenar las búsquedas recientes cronológicamente</string>
<string name="search_sort_summary">Si se habilita, se ordenan las búsquedas en orden cronológico. En caso contrario, se ordenan por nombre.</string>
</resources>

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