Compare commits

...

813 commits
3.5.0 ... dev

Author SHA1 Message Date
Alexander Capehart
e2918d3a95
musikr: fix failing tests 2025-03-22 16:48:12 -06:00
Alexander Capehart
01a159754d
musikr: move name tests to naming 2025-03-22 16:36:33 -06:00
Alexander Capehart
6a42f7c5d2
musikr: test tagparser
Created by claude 3.7
2025-03-22 10:56:57 -06:00
Alexander Capehart
e9b3649156
build: fix changelogs 2025-03-18 16:06:39 -06:00
Alexander Capehart
94795fe24c
build: bump to v4.0.4
Bump to version 4.0.4 (63).
2025-03-18 15:42:36 -06:00
Alexander Capehart
ef7ef8da95
musikr: miss covers when they cannot be decoded 2025-03-18 15:40:54 -06:00
Alexander Capehart
102ed85c42
musikr: reformat 2025-03-18 13:41:11 -06:00
Alexander Capehart
273dc971ba
musikr: fix fscovers scoring logic 2025-03-18 13:32:57 -06:00
Alexander Capehart
a3722acb5a
musikr: fix broken fscovers impl 2025-03-18 13:27:37 -06:00
Alexander Capehart
93953aee8b
about: fix mark pitblado sponsor entry text 2025-03-18 13:05:16 -06:00
Alexander Capehart
a71ef0daf2
settings: fix mark pitblado sponsor entry icon 2025-03-18 13:03:50 -06:00
Alexander Capehart
44633142d9
about: add mark pitblado to about sponsors 2025-03-18 13:02:20 -06:00
Alexander Capehart
9e683a7856
info: add mark pitblado to sponsors 2025-03-18 13:01:29 -06:00
Alexander Capehart
5825ec3ebc
musikr: consider parent dir name as cover file 2025-03-18 12:54:00 -06:00
Alexander Capehart
132b689b0c
musikr: offload storage dir creation to client 2025-03-18 11:56:04 -06:00
Alexander Capehart
e7454e636b
build: bump to v4.0.3
Bump to version 4.0.3 (62).
2025-03-18 11:03:51 -06:00
Alexander Capehart
159159b889
playback: fix media button intent handling
This actually broke in v3.6.0 or v3.5.0 I think, it just now appeared
in v4.0.0 for some reason.

This is a temporary fix, will rethink these intents later.
2025-03-17 19:22:28 -06:00
Alexander Capehart
b630063f8c
tasker: hack around new tasker issues
No idea why, but I guess I need to now wait an arbitrary amount of time
until I can actually let media button inputs in now.
2025-03-17 16:45:32 -06:00
Alexander Capehart
6b6fc4d62a
playback: fix ongoing sesson logic
Previous might have risked the state being blown up due to cascading
pause changes? Not sure honestly.
2025-03-17 15:21:47 -06:00
Alexander Capehart
93dee00285
playback: only save in ongoing session
Otherwise try not to in order to avoid causing state saving to fail.
2025-03-17 15:18:02 -06:00
Alexander Capehart
e73dffcb2a
musikr: fix limited buffer call 2025-03-17 14:51:57 -06:00
Alexander Capehart
296bd9ca06
musikr: recursively clean files
Helps clean out any previous folders from the old revisioned covers
system.
2025-03-17 14:47:15 -06:00
Alexander Capehart
7429dd5174
musikr: reformat 2025-03-17 14:02:13 -06:00
Alexander Capehart
6705e869da
app: remove unused night color resources 2025-03-17 14:01:53 -06:00
Alexander Capehart
77c9151006
list: dont attach fastscroll thumb to parent 2025-03-17 13:54:28 -06:00
Alexander Capehart
04e4ea82ed
musikr: cleanup docs 2025-03-17 13:53:56 -06:00
Alexander Capehart
a9707cbb33
musikr: document cache api 2025-03-17 13:05:47 -06:00
Alexander Capehart
f213c21225
musikr: compose dbcache 2025-03-17 12:54:39 -06:00
Alexander Capehart
e64b30f00f
musikr: revamp fscovers
Make it use a scoring system and properly document it.
2025-03-17 12:51:43 -06:00
Alexander Capehart
3df6e2f0b1
musikr: document/cleanup covers
Probably the first module I'm comfortable fully documenting.
2025-03-17 12:28:14 -06:00
Alexander Capehart
7523298237
musikr: fix build failure 2025-03-17 09:28:09 -06:00
Alexander Capehart
b21b2e49d3
musikr: split albums w/full album artist coverage
This is like the old Auxio behavior, but should now trigger with only
full album artist coverage, rather than before where it would always
trigger and break apart sparsely tagged albums.

Still not a perfect heuristic, but it's the best one I can do.
2025-03-17 09:15:35 -06:00
Alexander Capehart
eaba11fa44
music: fix with hidden defaults 2025-03-17 08:48:24 -06:00
Alexander Capehart
1193ef0bb9
app: cleanup resources 2025-03-17 08:46:15 -06:00
Alexander Capehart
aac6d8ef4d
app: cleanup 2025-03-17 08:12:39 -06:00
Alexander Capehart
343856ac69
musikr: bump cache db version 2025-03-17 07:39:39 -06:00
Alexander Capehart
90282f0f74
musikr: clean up data translation 2025-03-17 06:49:56 -06:00
Alexander Capehart
63227a1f1f
musikr: fix incorrect cache cleanups 2025-03-17 06:37:36 -06:00
Weblate (bot)
73b2b92180
Translations update from Hosted Weblate (#1034)
* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/

* Translated using Weblate (Czech)

Currently translated at 100.0% (316 of 316 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/

* Translated using Weblate (Italian)

Currently translated at 100.0% (316 of 316 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/it/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (316 of 316 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (316 of 316 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (316 of 316 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (316 of 316 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

* Translated using Weblate (German)

Currently translated at 100.0% (316 of 316 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/

* Translated using Weblate (Lithuanian)

Currently translated at 100.0% (316 of 316 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/

* Translated using Weblate (Lithuanian)

Currently translated at 100.0% (57 of 57 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/lt/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (316 of 316 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (316 of 316 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/

---------

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: lorenzoch02 <lrnz102002@gmail.com>
Co-authored-by: Eskuero <3skuero@gmail.com>
Co-authored-by: Максим Горпиніч <maksimgorpinic2005a@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Co-authored-by: qwerty287 <qwerty287@posteo.de>
Co-authored-by: Vaclovas Intas <Gateway_31@protonmail.com>
Co-authored-by: Adolfo Jayme Barrientos <fitojb@ubuntu.com>
2025-03-17 06:11:39 -06:00
Alexander Capehart
daf1687426
musikr: reformat 2025-03-15 22:30:14 -06:00
Alexander Capehart
8023d2c037
musikr: remove useless optin 2025-03-15 22:27:17 -06:00
Alexander Capehart
c2dcbd61f8
musikr: steamline loading pipeline
My hope is that overall this is more efficient and also easier to under
stand long-term.
2025-03-15 22:25:44 -06:00
Alexander Capehart
b3c66d9b55
musikr: reformat 2025-03-15 21:36:16 -06:00
Alexander Capehart
652f0891fc
musikr: use transcoding in storedcovers 2025-03-15 21:35:12 -06:00
Alexander Capehart
2f5b78dd84
musikr: fix cover storage dir check 2025-03-15 21:35:05 -06:00
Alexander Capehart
b8733a180c
musikr: reformat 2025-03-15 18:00:11 -06:00
Alexander Capehart
b573fd2260
musikr: make wildcard artists display covers
Resolves #1048
2025-03-15 17:55:59 -06:00
Alexander Capehart
436ef8de91
music: force listener trigger on location change
Otherwise it just won't actually update normally. Only for this
setting though. The others work just fine for some reason.
2025-03-15 17:37:04 -06:00
Alexander Capehart
05e864e7b5
app: remove storage perms
No longer needed, unsure why I didn't remove these.
2025-03-15 17:27:06 -06:00
Alexander Capehart
f030b440f6
Merge pull request #1049 from happilyretired23/patch-1
Fix typo in README.md
2025-03-13 09:58:54 -06:00
happilyretired23
513fd98047
Fix typo in README.md
Typo fix
2025-03-13 08:56:16 -07:00
Alexander Capehart
f125e37e95
musikr: fix build issue 2025-03-08 15:07:36 -07:00
Weblate (bot)
219d26b4dc
Translations update from Hosted Weblate (#1014)
* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (315 of 315 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (315 of 315 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/

* Translated using Weblate (Czech)

Currently translated at 100.0% (315 of 315 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (315 of 315 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ar/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (315 of 315 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

* Translated using Weblate (Finnish)

Currently translated at 100.0% (315 of 315 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/

* Translated using Weblate (Finnish)

Currently translated at 100.0% (56 of 56 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/fi/

* Translated using Weblate (German)

Currently translated at 100.0% (315 of 315 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/

* Translated using Weblate (German)

Currently translated at 100.0% (56 of 56 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/de/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (316 of 316 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (316 of 316 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/

---------

Co-authored-by: Максим Горпиніч <maksimgorpinic2005a@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: jonnysemon <jonnysemon@users.noreply.hosted.weblate.org>
Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Co-authored-by: Ricky Tigg <ricky.tigg@gmail.com>
Co-authored-by: qwerty287 <qwerty287@posteo.de>
Co-authored-by: santiago046 <comehere665@gmail.com>
Co-authored-by: Alexander Capehart <alex@oxycblt.org>
2025-03-08 15:01:08 -07:00
Alexander Capehart
879caf17db
musikr: revamp cover system
Retains the stateless attributes of the older system but massively
simplifies it compared to prior.
2025-03-08 14:53:43 -07:00
Alexander Capehart
cd535eda2e
all: fix merge issues 2025-03-08 11:03:35 -07:00
Alexander Capehart
e2d28f98f4
musikr: internalcovers -> embeddedcovers 2025-03-07 12:01:00 -07:00
Alexander Capehart
9a70ae1c4e
musikr: cleanup 2025-03-07 11:31:55 -07:00
Alexander Capehart
f5483b5bc5
Merge branch 'musikr-patches2' into dev 2025-03-07 11:23:32 -07:00
Alexander Capehart
e3715d3b2d
Merge branch 'dev' of github.com:OxygenCobalt/Auxio into dev 2025-03-07 11:05:26 -07:00
Alexander Capehart
0d05b94884
Merge branch 'master' into dev 2025-03-07 11:05:10 -07:00
Alexander Capehart
afa094d753
info: increase badge size 2025-03-05 16:43:28 -07:00
Alexander Capehart
cfa4fc30e1
info: add accrescent badge 2025-03-05 16:06:07 -07:00
Alexander Capehart
971c0e3a25
Merge pull request #1024 from OxygenCobalt/dev
v4.0.2
2025-03-04 18:42:12 -07:00
Alexander Capehart
9937e773a3
musikr: cleanup 2025-03-04 17:31:17 -07:00
Alexander Capehart
70b26dfb63
all: reformat 2025-03-04 15:53:34 -07:00
Alexander Capehart
ac1fec74da
musikr: actually attach child files to dir 2025-03-04 15:52:49 -07:00
Alexander Capehart
d62c85f8a5
musikr: avoid redundant dir child queries
Make parents async rather than children
2025-03-04 15:48:54 -07:00
Alexander Capehart
4de42a3a55
image: fix broken silo parsing 2025-03-04 15:45:57 -07:00
Alexander Capehart
4821051d34
music: fix broken default withhidden setting 2025-03-04 12:44:55 -07:00
Alexander Capehart
10a520e812
build: bump to v4.0.2
Bump to version 4.0.2 (61).
2025-03-04 12:33:50 -07:00
Alexander Capehart
95f615e980
info: update changelog 2025-03-04 12:28:37 -07:00
Alexander Capehart
e434c4cdfe
musikr: use correct ext stripping in name 2025-03-04 12:28:19 -07:00
Alexander Capehart
d9afc6a0eb
info: update changelog 2025-03-04 12:22:21 -07:00
Alexander Capehart
a2af205c71
musikr: simplify fixed extension stripping 2025-03-04 12:22:15 -07:00
Alexander Capehart
6b8c0faf44
musikr: include coverart.png files 2025-03-04 12:20:59 -07:00
Alexander Capehart
e092d81cf2
ui: fix dynamic black theme 2025-03-04 12:02:43 -07:00
Alexander Capehart
46806ee31f
ui: fix dynamic black theme dimming 2025-03-04 11:21:03 -07:00
Alexander Capehart
84a7393221
app: introduce dimmed black themes
Likely a bit better than the previous black themes

Can config dimming later.
2025-03-04 10:46:32 -07:00
Alexander Capehart
518cd28c03
musikr: reformat 2025-03-04 09:50:37 -07:00
Alexander Capehart
fe770337e6
musikr: fix segfault from logging nullptr 2025-03-04 09:46:41 -07:00
Alexander Capehart
1fc9ca5147
musikr: reformat 2025-03-03 20:47:51 -07:00
Alexander Capehart
387a36a3f8
musikr: fix hidden files setting 2025-03-03 20:47:03 -07:00
Alexander Capehart
20a06ba2fb
music: invert hidden files setting 2025-03-03 20:46:59 -07:00
Alexander Capehart
e046aeb671
musikr: invert hidden setting 2025-03-03 20:29:25 -07:00
Alexander Capehart
22249cc95b
musikr: cleanup 2025-03-03 20:13:38 -07:00
Alexander Capehart
6feee93438
musikr: streamline package structure 2025-03-03 19:59:11 -07:00
Alexander Capehart
0d0a20d760
musikr: simplify pipeline 2025-03-03 19:50:11 -07:00
Alexander Capehart
f0ea0a3e2e
all: reformat 2025-03-03 15:16:53 -07:00
Alexander Capehart
859e31d825
musikr: clean up as-is setting 2025-03-03 15:15:43 -07:00
Alexander Capehart
b48bf3729e
musikr: ignore stale folder covers 2025-03-03 15:10:46 -07:00
Alexander Capehart
4fbbbfdc76
app: introduce as-is covers
Risks extreme instability since I am no longer in control of format,
but some users just want very high-quality covers.
2025-03-03 13:09:26 -07:00
Alexander Capehart
a7000bc9e5
musikr: introduce folder covers
Like cover.png, cover.jpg, etc.
2025-03-03 12:41:30 -07:00
Alexander Capehart
8104985a4e
musikr: refactor devicefiles into tree 2025-03-03 12:14:40 -07:00
Alexander Capehart
fce77ec8a0
all: reformat 2025-03-01 21:43:58 -07:00
Alexander Capehart
a78b213537
musikr: fix build issues 2025-03-01 21:22:21 -07:00
Alexander Capehart
ce5f0fa2c9
all: reformat 2025-02-26 16:58:02 -07:00
Alexander Capehart
2e4a147b55
musikr: fix custom covers not being obtained 2025-02-26 16:57:22 -07:00
Alexander Capehart
216d9802ef
all: reformat 2025-02-26 16:14:18 -07:00
Alexander Capehart
7906867a96
image: implement compat covers backport
For cover.jpg users
2025-02-26 16:14:06 -07:00
Alexander Capehart
25901a0f76
musikr: make cover creation more flexible
Enables some compat cover changes I need to make.
2025-02-26 14:52:13 -07:00
Alexander Capehart
403f93b6df
musikr: backport breaking uid change
v401 UIDs once again drifted because of the broken extensions.
2025-02-25 17:40:06 -07:00
Weblate (bot)
0bbba2efaf
Merge pull request #997 from weblate/weblate-auxio-strings
Translations update from Hosted Weblate
2025-02-25 17:35:34 -07:00
Alexander Capehart
3741f1ff07
musikr: fix build error 2025-02-25 17:33:47 -07:00
Alexander Capehart
b388474655
musikr: reformat 2025-02-25 16:14:41 -07:00
Alexander Capehart
584af83a07
musikr: parallelize all extraction 2025-02-25 16:14:30 -07:00
Alexander Capehart (aider)
0387400a4a
refactor: Simplify ExtractStep with unified parallel processing flow 2025-02-25 16:09:04 -07:00
Alexander Capehart
94f8457d69
musikr: reformat 2025-02-25 16:02:10 -07:00
Alexander Capehart
876554e6c7
ui: add missing string resources 2025-02-25 16:02:09 -07:00
Alexander Capehart
22b231843f
all: update .gitignore 2025-02-25 16:02:09 -07:00
Alexander Capehart
be270a422b
musikr: fix build issues 2025-02-25 16:02:09 -07:00
Alexander Capehart (aider)
10eb0be7d0
music: add setting to ignore hidden files during music loading 2025-02-25 16:02:05 -07:00
Alexander Capehart (aider)
e2b0601d4c
musikr: add option to ignore hidden files/directories 2025-02-25 15:44:37 -07:00
Alexander Capehart
ddeba2c496
musikr: correctly strip extensions from files
Otherwise I can mangle filenames that are just dots.
2025-02-25 09:11:27 -07:00
Alexander Capehart
59c33b9be2
info: fix screenshot size 2025-02-24 11:50:34 -07:00
Alexander Capehart
cacf0142c5
info: more screenshot fixes 2025-02-24 11:48:43 -07:00
Alexander Capehart
fbcd676149
info: fix screenshot typography 2025-02-24 11:43:43 -07:00
Alexander Capehart
6cc1e8a543
musikr: fix fastlane image issues 2025-02-24 11:04:50 -07:00
Alexander Capehart
8a8fd0f3c9
Merge pull request #1009 from OxygenCobalt/dev
v4.0.1
2025-02-24 10:03:55 -07:00
Alexander Capehart
98299722bc
musikr: add back mbids to backported uids 2025-02-24 09:41:49 -07:00
Alexander Capehart
91b8b38732
Revert "ui: attempt to fix a15 e2e enforcement bugs"
This reverts commit bfcaba4acd.
2025-02-24 09:29:37 -07:00
Alexander Capehart
d6c2514473
build: bump to v4.0.1
Bump to version 4.0.1 (60).
2025-02-24 09:09:55 -07:00
Alexander Capehart
50e2dde6e2
musikr: remove pipeline logs 2025-02-24 07:54:12 -07:00
Alexander Capehart
582b0c6eef
musikr: fix uid compat issues 2025-02-24 07:53:20 -07:00
Alexander Capehart
3834e92192
musikr: add backwards compat for v4 uids
This annoying hack should be temporary once I can build a new UID
system and migrate everything over.
2025-02-24 07:02:03 -07:00
Alexander Capehart
117678a066
musikr: fix metadata drift
Largely a temporary compat measure to avoid playlist destruction, will
retire UIDs for a new system soon which should give me the ability to
rethink the spec.
2025-02-22 22:37:09 -07:00
Alexander Capehart
b306456d46
musikr: fix hang on metadata extraction
When files read all the way to EOF.
2025-02-22 21:03:44 -07:00
Alexander Capehart
1d44ce5d71
Merge branch 'master' into dev 2025-02-21 17:52:12 -07:00
Alexander Capehart
bfcaba4acd
ui: attempt to fix a15 e2e enforcement bugs
Certain devices (mostly Sony for some reason???) have bugged e2e
enforcement that actually breaks it on my app. Try to see if we can
"fix" this by disabling the enforcement using the optOut flag.
2025-02-21 17:51:30 -07:00
Alexander Capehart
442abb7040
Merge pull request #999 from OxygenCobalt/dev
Backport accidental logging to v4.0.0 tag
2025-02-21 15:35:19 -07:00
Alexander Capehart
251197b47b
musikr: accidental logging
Including this for posterity with the actual release build
2025-02-21 15:05:13 -07:00
Alexander Capehart
52e359d431
Merge pull request #998 from OxygenCobalt/dev
Version 4.0.0
2025-02-21 14:47:29 -07:00
Alexander Capehart
f21ef6cf85
info: update branding 2025-02-21 14:37:39 -07:00
Alexander Capehart
c609e1d63a
settings: enable default round mode in settings 2025-02-21 13:09:37 -07:00
Alexander Capehart
b6af921238
musikr: enable round mode by default 2025-02-21 13:08:25 -07:00
Alexander Capehart
528389546c
info: add fastlane changelog 2025-02-21 13:07:57 -07:00
Alexander Capehart
2ff08ac813
ui: enable rounded covers by default
Not removing the setting, round covers just seems to be more popular.
2025-02-21 13:07:13 -07:00
Alexander Capehart
db4e927780
build: bump to 4.0.0
Bump to version 4.0.0 (59).
2025-02-21 13:01:08 -07:00
Alexander Capehart
c3aba06e2f
musikr: dont keep tagjni symbols 2025-02-21 12:56:38 -07:00
Alexander Capehart
3d374504e2
musikr: strip down taglib
Only to supported ExoPlayer formats.
2025-02-21 12:56:38 -07:00
Weblate (bot)
a6a98f9bf7
Translations update from Hosted Weblate (#939)
* Translated using Weblate (Portuguese)

Currently translated at 98.7% (309 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt/

* Translated using Weblate (Arabic (ar_IQ))

Currently translated at 69.9% (219 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ar_IQ/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/

* Translated using Weblate (Lithuanian)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/

* Translated using Weblate (Italian)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/it/

* Translated using Weblate (Finnish)

Currently translated at 97.7% (306 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/tr/

* Translated using Weblate (Turkish)

Currently translated at 100.0% (54 of 54 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/tr/

* Translated using Weblate (French)

Currently translated at 96.4% (302 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/

* Translated using Weblate (Finnish)

Currently translated at 100.0% (54 of 54 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/fi/

* Translated using Weblate (Swedish)

Currently translated at 99.6% (312 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sv/

* Added translation using Weblate (Georgian)

* Translated using Weblate (Finnish)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/

* Translated using Weblate (Georgian)

Currently translated at 6.0% (19 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ka/

* Added translation using Weblate (Nepali)

* Translated using Weblate (Nepali)

Currently translated at 98.1% (53 of 54 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/ne/

* Translated using Weblate (Nepali)

Currently translated at 0.6% (2 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ne/

* Translated using Weblate (Nepali)

Currently translated at 20.4% (64 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ne/

* Translated using Weblate (Portuguese)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt/

* Translated using Weblate (Norwegian Nynorsk)

Currently translated at 96.4% (302 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/nn/

* Translated using Weblate (Croatian)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/

* Translated using Weblate (Croatian)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/

* Translated using Weblate (Georgian)

Currently translated at 6.0% (19 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ka/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 18.2% (57 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hant/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ar/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 36.7% (115 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hant/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 46.9% (147 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hant/

* Translated using Weblate (Albanian)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sq/

---------

Co-authored-by: santiago046 <comehere665@gmail.com>
Co-authored-by: Ahmed Khaleel Shihab <ahmed91shihab@gmail.com>
Co-authored-by: Vaclovas Intas <Gateway_31@protonmail.com>
Co-authored-by: lukeearthwalker0 <lukeearthwalker33@gmail.com>
Co-authored-by: Ricky Tigg <ricky.tigg@gmail.com>
Co-authored-by: Quint <quintus3236@protonmail.com>
Co-authored-by: Cyxae Dexyc <cyxae@amphitryon.nrst.fr>
Co-authored-by: gummyhulk <hatsunemiku99@protonmail.com>
Co-authored-by: Demetre Ph <demetre.phalavandishvili@gmail.com>
Co-authored-by: Joonas Reinholm <joonas.reinholm@pm.me>
Co-authored-by: Languages add-on <noreply-addon-languages@weblate.org>
Co-authored-by: pes18fan <realitaetinbewegung@proton.me>
Co-authored-by: Bruno Fragoso <darth_signa@hotmail.com>
Co-authored-by: Øystein Alværvik <cave_allegory@users.noreply.hosted.weblate.org>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Mat <tukanmm17@gmail.com>
Co-authored-by: Temuri Doghonadze <temuri.doghonadze@gmail.com>
Co-authored-by: Allam Contreras <allamc1197@gmail.com>
Co-authored-by: Jinzhou Huang <2314662431@qq.com>
Co-authored-by: jonnysemon <jonnysemon@users.noreply.hosted.weblate.org>
Co-authored-by: D <dici.handy@gmail.com>
2025-02-21 12:55:40 -07:00
Alexander Capehart
a7969f99c3
build: bump taglib 2025-02-21 12:34:35 -07:00
Alexander Capehart
dddab1eda7
service: try fixing foreground errors
Probably caused by services being sticky.
2025-02-21 12:32:24 -07:00
Alexander Capehart
9ae3587a7e
detail: better fix no playlist nav
Actually a problem with the dialog. Very annoying execution flow bug.
2025-02-21 11:45:53 -07:00
Alexander Capehart
6589cd44eb
Revert "detail: fix up navigation"
This reverts commit bfdccd3ba5.
2025-02-21 11:31:09 -07:00
Alexander Capehart
bfdccd3ba5
detail: fix up navigation
No clue why this is happening, but I assume another classic android
moment has occurred and now I can't navigate away when objects are
invalidated unless I navigate away twice. Because reasons. Amazingly
designed platform.

Resolves #989.
2025-02-21 10:57:22 -07:00
Alexander Capehart
357c7cc329
musikr: reformat 2025-02-21 09:34:37 -07:00
Alexander Capehart
e442fcf253
musikr: fix use-after-free in jni 2025-02-21 09:30:37 -07:00
Alexander Capehart
a1d62c2a08
musikr: fix dupliate artist vertices when melding 2025-02-10 15:07:26 -07:00
Alexander Capehart
3d154ea66c
service: further delay start
Hopefully the foreground limiter "likes" this.
2025-02-10 14:04:47 -07:00
Alexander Capehart
3efd4ea59f
playback: fix playback sheet hiding on pre-U back
Caused by a missed check.
2025-02-10 14:00:01 -07:00
Alexander Capehart
9632e06ca6
musikr: fix build problems 2025-02-10 13:44:49 -07:00
Alexander Capehart
210285b39a
home: remove adaptive tabs
More hassle than they are worth.
2025-02-10 13:43:25 -07:00
Alexander Capehart
15d2faf354
build: bump media 2025-02-10 13:36:50 -07:00
Alexander Capehart
1459498ff3
musikr.metadata: handle case w/no mp4 covers
Otherwise could have triggered an exception.
2025-02-06 14:54:43 -07:00
Alexander Capehart
ef732219d7
info: clarify taglib in readme 2025-02-06 11:41:09 -07:00
Alexander Capehart
431f541ec8
info: add slushspirit to sponsors 2025-02-06 11:27:26 -07:00
Alexander Capehart
fb2f228a97
Update README.md 2025-02-06 11:26:28 -07:00
Alexander Capehart
72ffac4209
musikr: reformat 2025-01-31 13:45:18 -07:00
Alexander Capehart
ee7e63d1dc
musikr: stop possible use-after-free in metabuild 2025-01-31 13:41:23 -07:00
Alexander Capehart
f9109b8a9c
musikr.build: enable symbols in taglibjni w/debug 2025-01-31 11:09:35 -07:00
Alexander Capehart
2e4b6681d1
build: bump to v4.0.0-dev5 2025-01-30 12:50:10 -07:00
Alexander Capehart
a0c82ac812
musikr: reformat 2025-01-30 09:38:38 -07:00
Alexander Capehart
c881a1c5b4
build: bump coroutines to 1.10.1
Fixes some more coroutine memory leaks.
2025-01-30 09:37:30 -07:00
Alexander Capehart
e78fde44e0
Revert "musikr: use channel-based pipeline"
This reverts commit 7c8863bd3a.
2025-01-30 09:30:38 -07:00
Alexander Capehart
7880c777ba
musikr: fix inputstream memory leak
Apparently allocating the bytes on the JVM side of the taglib parser
will wind up leaking memory due to a bugged cache in ByteBuffer.

Instead, allocate the bytes in native, wrap it into a ByteBuffer, and
then pass it upwards into NativeInputStream. This seems to fix the
leak.
2025-01-30 09:29:26 -07:00
Alexander Capehart
7c8863bd3a
musikr: use channel-based pipeline
Much more repeated code, but no more memory leaks.
2025-01-29 15:31:48 -07:00
Alexander Capehart
97bd259728
musikr: handle duplicate playlist songs in graph
Accidentally flattened these out during graphing.
2025-01-22 12:57:11 -07:00
Alexander Capehart
e3e19fb0ac
playback: avoid huge allocation on printing cmd 2025-01-21 13:18:23 -07:00
Alexander Capehart
9685f3cf51
musikr: fix broken jni build 2025-01-20 11:40:15 -07:00
Alexander Capehart
9d22cc37b8
musikr: report invalid songs in pipeline
Avoids the bar getting "stuck"
2025-01-20 11:39:47 -07:00
Alexander Capehart
d49286981c
musikr: improve native error handling
Not an ideal error reporting system, but for the purposes of getting
4.0.0 out as fast as possible it will do.
2025-01-20 11:26:41 -07:00
Alexander Capehart
0785711cd6
musikr.tag: handle slash positions in xiph
Resolves #965.
2025-01-18 20:32:28 -07:00
Alexander Capehart
a0e10ef8dd
musikr: implement raii jni classes
This should hopefully mitigate the memory leak problems unless I forget
to transfer over ref ownership to the corresponding class. Analyzed
memory use on load and it looks like the JVM is able to reclaim
everything extracted by the native code, so I should hopefully be fine.
2025-01-18 19:52:05 -07:00
Alexander Capehart
1bf44eba91
musikr: fix memory leaks 2025-01-18 17:21:14 -07:00
Alexander Capehart
3aae8ea534
musikr: bubblewrap nativeinputstream
Try to avoid exceptions cascading and bringing down the app.
2025-01-18 09:58:05 -07:00
Alexander Capehart
b81ecf44c0
all: reformat 2025-01-16 09:40:36 -07:00
Alexander Capehart
020c6900a5
all: fix build issues 2025-01-15 11:44:57 -07:00
Alexander Capehart
4d704e86a6
Revert "musikr: bubblewrap jvminputstream"
This reverts commit b6d80189ca.
2025-01-15 11:44:29 -07:00
Alexander Capehart
ad2ec5a655
Revert "app: remove custom edge to edge setup"
This reverts commit f134d3e11b.
2025-01-15 11:44:18 -07:00
Alexander Capehart
b0b55b5069
main: band-aid bottom sheets flipping out pre-30
Caused by busted legacy window insets behavior biting me again.
2025-01-15 11:39:59 -07:00
Alexander Capehart
c9d4b01f9f
musikr: initial root documentation 2025-01-14 08:55:44 -07:00
Alexander Capehart
b6d80189ca
musikr: bubblewrap jvminputstream
Should help me ID some error.
2025-01-14 08:53:03 -07:00
Alexander Capehart
71aa887438
musikr.cache: bump version 2025-01-13 19:35:03 -07:00
Alexander Capehart
b108970fe5
build: bump to 4.0.0-dev4 2025-01-13 19:27:24 -07:00
Alexander Capehart
f28f2dd9f7
playback: fix dropped saved state on empty lib
Since ExoPlaybackStateHolder wasn't handling the new "empty
library" case.
2025-01-13 19:24:43 -07:00
Alexander Capehart
847d5aa1fc
all: reformat 2025-01-13 12:20:10 -07:00
Alexander Capehart
e1f07def10
settings: recreate on theme change
Possibly mitigates some new edge to edge issues.
2025-01-13 12:09:19 -07:00
Alexander Capehart
f134d3e11b
app: remove custom edge to edge setup
I think this conflicts with the weird default behavior of Android 15.
2025-01-13 11:56:46 -07:00
Alexander Capehart
10aaf0afd2
all: reformat 2025-01-11 20:02:00 -07:00
Alexander Capehart
a1289ffaca
service: attempt to band-aid foreground limit 2025-01-11 20:01:33 -07:00
Alexander Capehart
ad4b9a3859
playback: re-add file playback 2025-01-11 19:52:27 -07:00
Alexander Capehart
08e09af5b3
all: reformat 2025-01-11 19:16:59 -07:00
Alexander Capehart
cc6c5084ff
playback: reduce more skipping on tight reloads 2025-01-11 19:15:18 -07:00
Alexander Capehart
2f43113ce2
ui: make brown/grey themes distinct
These would otherwise be red and blue unless I enable color match.
2025-01-11 16:24:39 -07:00
Alexander Capehart
04e871f421
all: reformat 2025-01-11 10:18:36 -07:00
Alexander Capehart
698f0bc13c
detail: fix bouncing when navigating to song 2025-01-11 10:14:59 -07:00
Alexander Capehart
85a2952ae1
main: fix fab shadow
By reverting the previous changes to stop touch events from being
eaten?

Not sure why this works.
2025-01-11 10:14:59 -07:00
Alexander Capehart
c35902a6aa
Merge pull request #950 from dot166/dev
add android 15 to android version list in issue template
2025-01-10 11:10:57 -07:00
Alexander Capehart
1132e486ca
home: do not convert addedms to to secs 2025-01-09 19:33:14 -07:00
Alexander Capehart
e6b326a571
musikr: clarify album added timestamp api
Same reasons, should be milliseconds
2025-01-09 19:31:48 -07:00
Alexander Capehart
ae6a0438be
musikr: clarify added/modified timestamp apis
Clearly indicate their new millisecond nature.
2025-01-09 19:30:32 -07:00
Alexander Capehart
c359048721
playback: remove unused button theme 2025-01-09 12:54:48 -07:00
Alexander Capehart
29320f426e
playback: dont use off-standard colors for btns
Use colorSecondary instead of colorPrimaryFixedDim
2025-01-09 12:51:12 -07:00
Alexander Capehart
8bd89c5967
musikr: ignore genre numbers of 255 2025-01-08 18:27:08 -07:00
Alexander Capehart
9b82b5aee0
build: bump to 4.0.0-dev3 2025-01-08 18:19:37 -07:00
Alexander Capehart
c5241dec60
app: reformat 2025-01-08 18:06:18 -07:00
Alexander Capehart
998375f28a
home: stop fabs from eating touch events 2025-01-08 18:02:56 -07:00
Alexander Capehart
e0059e9dc0
musikr: reformat 2025-01-08 17:19:02 -07:00
Alexander Capehart
3d690eb637
musikr: fix graphing error w/certain link steps
I wasn't correctly linking genres, which would cascade to a dead vertex
down the line.

Will need better diagnostics here.
2025-01-08 17:17:05 -07:00
Alexander Capehart
0e34a28dfb
musikr: fix stream seeking
Foolishly changed offset sign in seek from end.
2025-01-08 15:49:49 -07:00
Alexander Capehart
8c3750778f
musikr: add id3v1 support
Forgot to go ahead and implement this.
2025-01-08 15:06:25 -07:00
Alexander Capehart
802e215482
musikr: remove extractstep debug logging
Not needed right now
2025-01-08 15:05:50 -07:00
._______166
6ee43b106f
add android 15 to android version list in issue template 2025-01-08 22:03:39 +00:00
Alexander Capehart
f8ec77e137
main: fix unusable fast scroll below fab 2025-01-08 13:19:04 -07:00
Alexander Capehart
4a08809e50
home: hide loading indicator by default
Prevents flickering during navigation.
2025-01-08 12:58:58 -07:00
Alexander Capehart
8c4b8dfb56
musikr: improve dead vertex error reporting 2025-01-08 12:53:04 -07:00
Alexander Capehart
ff074d0e3a
all: fix formatting 2025-01-08 12:42:44 -07:00
Alexander Capehart
3bd4027802
home: add retry to error dialog 2025-01-08 12:34:24 -07:00
Alexander Capehart
6f2b7abbef
music: commit playlist rewrites 2025-01-08 12:10:42 -07:00
Alexander Capehart
58e0956cad
musikr: dont stop parsing mp4 atoms
I cannot believe I have made this mistake twice.
2025-01-08 11:31:52 -07:00
Alexander Capehart
e94b74edd4
musikr: do custom picture handling
TagLib's picture handling is inadequate for our use case.
2025-01-08 11:15:56 -07:00
Alexander Capehart
b3f4fdfb4a
build: bump version
Bump to version 4.0.0-dev2.
2025-01-08 10:37:03 -07:00
Alexander Capehart
e519e8f8be
musikr: handle null tags 2025-01-07 19:34:30 -07:00
Alexander Capehart
ed3e0845d6
musikr: more debug logging
Trying to track down this thorny segfault.
2025-01-07 18:31:59 -07:00
Alexander Capehart
5375c862b3
info: further standardize splash 2025-01-07 17:24:13 -07:00
Alexander Capehart
4318e70052
info: make splash branding better 2025-01-07 13:10:40 -07:00
Alexander Capehart
7b9c14a118
musikr: add temp logging 2025-01-07 13:05:32 -07:00
Alexander Capehart
0ead77d6e6
info: switch splash motion 2025-01-07 12:40:17 -07:00
Alexander Capehart
6a6d15f3e8
info: tweak splash 2025-01-07 12:30:42 -07:00
Alexander Capehart
605800e9a5
musikr: handle possible null pointers in id3v2 2025-01-07 10:02:28 -07:00
Alexander Capehart
447f2da294
info: update icon
Use a new stacked design that is a lot more in line with M3 icon
design.
2025-01-06 22:03:31 -07:00
Alexander Capehart
3b97c61b7d
build: change version code to -dev
As is convention
2025-01-06 14:27:36 -07:00
Alexander Capehart
2b46774215
musikr: fix internal frame parsing 2025-01-06 14:12:24 -07:00
Alexander Capehart
1d84ba23b4
build: update submodules 2025-01-06 13:54:22 -07:00
Alexander Capehart
fdf71cedd2
musikr: fix formatting 2025-01-06 13:17:27 -07:00
Alexander Capehart
5e168860e7
musikr: bundle cleanup into api
Prevents as much footguns.
2025-01-06 13:16:31 -07:00
Alexander Capehart
6587d2259b
all: reformat 2025-01-06 11:44:13 -07:00
Alexander Capehart
b328a6ea03
musikr: add temp logging
To debug metadata issues.
2025-01-06 11:41:01 -07:00
Alexander Capehart
298a30da6d
image: fix provider caching issues
- Covers would hypothetically not be updated in android auto
if the setting changed to off
- Cover fetching might fail in weird ways due to the current
error throwing
2025-01-06 11:32:03 -07:00
Alexander Capehart
bbc4db156e
musikr: fix equality issues 2025-01-06 11:23:55 -07:00
Alexander Capehart
1fb6097b9d
all: reformat 2025-01-06 08:29:15 -07:00
Alexander Capehart
9952579cc4
musikr.tag: fix correction creating empty tag lists 2025-01-06 08:28:56 -07:00
Alexander Capehart
6d09e06424
list: fix fastscroll layout issues 2025-01-06 08:26:17 -07:00
Alexander Capehart
3e54c032fe
app: fix cover provider authority conflict
Between release and debug builds
2025-01-06 08:26:13 -07:00
Alexander Capehart
6be97943bc
musikr: fix broken minification 2025-01-06 08:15:28 -07:00
Alexander Capehart
4679785b78
list: update fastscrollrecyclerview credits 2025-01-04 17:53:18 -07:00
Alexander Capehart
9fe508a906
all: fix formatting 2025-01-04 17:51:40 -07:00
Alexander Capehart
156b2fe1f0
list: fix fast scroller haptics 2025-01-04 17:51:15 -07:00
Alexander Capehart
4809bf50cc
build: fix min sdk 2025-01-04 17:47:37 -07:00
Alexander Capehart
d486dc39cc
list: add haptic feedback to popup scroll 2025-01-04 17:47:19 -07:00
Alexander Capehart
710e279d8f
musikr: tweak api 2025-01-04 17:43:09 -07:00
Alexander Capehart
9166580703
all: remove debug logs 2025-01-04 16:03:31 -07:00
Alexander Capehart
32b152e155
musikr: reformat 2025-01-04 15:57:03 -07:00
Alexander Capehart
a4d7b54db7
musikr: add back tag whitespace fixes
Requires me to rejig the JNI integration, but it's overall good since
it allows me to strip away a lot of the logic.
2025-01-04 15:56:19 -07:00
Alexander Capehart
fddd527975
musikr.tag: handle compilation flag 2025-01-04 13:42:28 -07:00
Alexander Capehart
3431e13cde
image: fix format problem 2025-01-04 12:55:27 -07:00
Alexander Capehart
2d5ca0b351
music: connect mediaitems to cover provider 2025-01-04 12:54:40 -07:00
Alexander Capehart
07a0d01a06
image: fix bad coverprovider conventions 2025-01-04 12:54:24 -07:00
Alexander Capehart
b4a9f9af96
image: fix broken cover provider fetching 2025-01-04 12:52:27 -07:00
Alexander Capehart
b0faad6380
build: bump to version 4.0.0-beta1 2025-01-04 12:14:37 -07:00
Alexander Capehart
20be8c17fe
music: complete indexing after post-update steps
Not the most ideal, but results in less state bugs with the
current jank "pick folder" visibility in home.
2025-01-04 12:07:57 -07:00
Alexander Capehart
3007ad3ced
detail: re-add toolbar play/shuffle 2025-01-04 11:26:35 -07:00
Alexander Capehart
92a07e346b
detail: re-add grid view
Not going to do tablet layout right now in favor of shipping faster.
2025-01-04 11:08:46 -07:00
Alexander Capehart
7e6865c6b3
all: reformat 2025-01-04 11:08:01 -07:00
Alexander Capehart
533702ca1e
list: fix poor fast scroll empty state handling 2025-01-04 11:03:59 -07:00
Alexander Capehart
171c0c795e
list: re-add fast scroll thumb 2025-01-04 10:57:47 -07:00
Weblate (bot)
4c58590cb0
Translations update from Hosted Weblate (#938)
* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/

* Translated using Weblate (Czech)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/

* Translated using Weblate (German)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

* Translated using Weblate (Polish)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pl/

* Translated using Weblate (Russian)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/

* Translated using Weblate (Belarusian)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/

---------

Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: qwerty287 <qwerty287@posteo.de>
Co-authored-by: Максим Горпиніч <maksimgorpinic2005a@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Co-authored-by: Maciej Klupp <maciej.klupp@gmail.com>
Co-authored-by: Макар Разин <makarrazin14@gmail.com>
2025-01-03 17:36:04 -07:00
Alexander Capehart
0c7adc9d17
Update README.md 2025-01-03 15:37:32 -07:00
Alexander Capehart
88d5d398c5
list: enable fast scroll thumb by default 2025-01-03 15:22:10 -07:00
Alexander Capehart
d5b2397511
info: update changelog 2025-01-03 15:19:37 -07:00
Alexander Capehart
1594340046
all: reformat 2025-01-03 14:46:16 -07:00
Alexander Capehart
ab81995d1c
detail: enable fast scrolling
Finally possible with the new thumb enabling/disabling and scroll
design.
2025-01-03 14:44:58 -07:00
Alexander Capehart
bf9b842407
list: implement fast scroll thumb disabling 2025-01-03 14:44:17 -07:00
Alexander Capehart
f5ac87a36b
detail: use only linear recyclerviews
Going to switch to a two-pane layout.
2025-01-03 14:33:31 -07:00
Alexander Capehart
ecc8d8750a
list: make fast scroll thumb less intrusive 2025-01-02 12:09:03 -07:00
Alexander Capehart
be666069fc
home: fix broken genre no music label 2025-01-01 16:31:06 -07:00
Alexander Capehart
b65481dd9c
home: disable action during loading 2025-01-01 16:31:06 -07:00
Weblate (bot)
56ff872f04
Translations update from Hosted Weblate (#935)
* Translated using Weblate (Croatian)

Currently translated at 100.0% (311 of 311 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/

* Translated using Weblate (Welsh)

Currently translated at 99.6% (310 of 311 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cy/

* Translated using Weblate (German)

Currently translated at 100.0% (312 of 312 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/

* Translated using Weblate (Czech)

Currently translated at 100.0% (312 of 312 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (312 of 312 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (312 of 312 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/

* Translated using Weblate (Hebrew)

Currently translated at 100.0% (54 of 54 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/he/

* Translated using Weblate (Hebrew)

Currently translated at 100.0% (312 of 312 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/he/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (312 of 312 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

* Translated using Weblate (Finnish)

Currently translated at 99.6% (311 of 312 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/

* Translated using Weblate (Czech)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/

* Translated using Weblate (German)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/

* Translated using Weblate (Polish)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pl/

* Translated using Weblate (Swedish)

Currently translated at 98.1% (53 of 54 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/sv/

* Translated using Weblate (Swedish)

Currently translated at 96.1% (301 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sv/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

* Translated using Weblate (Croatian)

Currently translated at 100.0% (313 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/

* Translated using Weblate (Azerbaijani)

Currently translated at 42.1% (132 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/az/

---------

Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: fin-w <fin-w@users.noreply.hosted.weblate.org>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Максим Горпиніч <mgorpinic2005@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Martin K <martyshkon@gmail.com>
Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Co-authored-by: Jiri Grönroos <jiri.gronroos@iki.fi>
Co-authored-by: qwerty287 <qwerty287@posteo.de>
Co-authored-by: Maciej Klupp <maciej.klupp@gmail.com>
Co-authored-by: gummyhulk <hatsunemiku99@protonmail.com>
Co-authored-by: Femini <nizamismidov4@gmail.com>
Co-authored-by: Alexander Capehart <alex@oxycblt.org>
2025-01-01 16:12:05 -07:00
Alexander Capehart
c3ccb8519e
musikr: add empty library check 2025-01-01 16:10:34 -07:00
Alexander Capehart
9161b8f777
home: make no music action generic
This way the playlist view can switch to "New Playlist" if a load
finishes but the user hasn't made any playlists.
2025-01-01 16:07:36 -07:00
Alexander Capehart
0f4a550775
home: disable click listener when no error 2025-01-01 15:55:15 -07:00
Alexander Capehart
028fff4c42
home: use correct icons for no music indicators 2025-01-01 15:52:46 -07:00
Alexander Capehart
d61c2852e6
home: simplify no music indicator
This is not the most ideal communication of the UI, but for the sake
of delivering faster this will be the setup until I can implement some
kind of scaffold system (which will be annoying and hard).
2025-01-01 15:44:30 -07:00
Alexander Capehart
bb8dfdb28a
home: dont hide existing items during load 2025-01-01 15:32:57 -07:00
Alexander Capehart
a2e6bcbb7f
musikr: separate immutable/mutable subclasses
This makes it easier for me to centralize certain DI.
2025-01-01 14:37:01 -07:00
Alexander Capehart
194e6b1574
image: introduce cover provider
This will be used to expose image data to android auto.
2025-01-01 14:21:44 -07:00
Alexander Capehart
62e214039f
all: reformat 2025-01-01 13:59:40 -07:00
Alexander Capehart
75455b1b90
musikr: make cover files more concrete
This should allow me to implement a solid ContentProvider.
2025-01-01 13:58:52 -07:00
Alexander Capehart
2401f9031f
music: connect update tracker to service 2025-01-01 13:56:59 -07:00
Alexander Capehart
04e81916f7
all: fix formatting 2025-01-01 13:14:06 -07:00
Alexander Capehart
68098b97ed
music: move automatic reloading to musikr 2025-01-01 13:08:53 -07:00
Alexander Capehart
ef751f1a11
home: add back error screen click handler 2025-01-01 12:24:22 -07:00
Alexander Capehart
7497ff2514
musikr: fix crash when no mbids used 2024-12-28 15:11:28 -07:00
Alexander Capehart
c6dc51659b
home: fix loading indicator dimensions 2024-12-28 14:59:42 -07:00
Alexander Capehart
9ccc4cf2ae
home: make loading indicator less intrusive 2024-12-28 14:54:20 -07:00
Alexander Capehart
64ce312976
image: reduce save space cover size
This gets the image storage size down to like 8 megs on my library,
seems solid enough.
2024-12-28 15:22:15 -06:00
Alexander Capehart
25ca3e3046
music: update cover mode settings entry
To use the new values.
2024-12-28 15:20:24 -06:00
Alexander Capehart
e78e71e3a7
image: fix broken cover module 2024-12-28 15:12:10 -06:00
Alexander Capehart
a1cd4f7b26
music: re-add configurable covers 2024-12-28 13:15:44 -06:00
Alexander Capehart
ff6d2fe228
music: move cover impl to image 2024-12-28 09:53:16 -05:00
Alexander Capehart
c6e83d1e18
musikr: introduce null covers
Will be used once covers are made configurable.
2024-12-28 09:51:46 -05:00
Alexander Capehart
d3f4ed5dd4
musikr: separate silo and covers 2024-12-27 15:51:14 -05:00
Alexander Capehart
d964df4616
musikr: fix broken cover cleanup 2024-12-27 15:49:26 -05:00
Alexander Capehart
b05d668b5e
musikr: close cover output streams 2024-12-27 15:39:31 -05:00
Alexander Capehart
292ea9d8a1
musikr: fix broken siloed covers
Caused by a missing param equality.
2024-12-27 15:38:49 -05:00
Alexander Capehart
ebcedb49eb
musikr: fix broken cache pruning 2024-12-27 15:38:23 -05:00
Alexander Capehart
8b3d7cae9c
musikr: handle missing covers on recaching
Now that we have effectively two caches (The main cache and the covers),
we have to handle the case where we have cached data, but the cover data
is missing. This is a real-world edge case once album covers are made
configurable as they were previously.
2024-12-27 15:11:09 -05:00
Alexander Capehart
32156f23b2
music: introduce siloed covers
Will allow me to dynamically configure cover quality by user settings.
2024-12-27 10:38:38 -05:00
Alexander Capehart
8b58f357cb
musikr: introduce cover cleanup
Helps reduce overall memory use.
2024-12-27 10:06:04 -05:00
Alexander Capehart
7b35ba840b
music: tweak revisioned cover api 2024-12-27 09:51:18 -05:00
Alexander Capehart
0dc72b67af
musikr: introduce cover params 2024-12-26 20:26:04 -05:00
Alexander Capehart
80c97cbea1
musikr: separate cover files/format 2024-12-26 19:54:31 -05:00
Alexander Capehart
b8178056f5
musikr: simplify cover storage boundaries 2024-12-26 19:23:48 -05:00
Alexander Capehart
dc8cbc74e8
all: fix formatting 2024-12-26 18:55:47 -05:00
Alexander Capehart
5e7d575efd
build: fix cpp formatting 2024-12-26 18:53:24 -05:00
Alexander Capehart
8d49893309
music: redesign music sources dialog
Now based around a more conventional design now that I no longer
need all the bells and whistles around include/exclude.
2024-12-26 18:22:28 -05:00
Alexander Capehart
75612dd1eb
all: cleanup 2024-12-26 14:04:15 -05:00
Alexander Capehart
61fd11fe04
musikr: refactor cache api
To make the pruning system more agnostic and "extendable"
2024-12-26 13:58:23 -05:00
Alexander Capehart
3f364dc5c6
musikr: add cache pruning
Helps remove dead entries, and additionally makes date added values more
accurate over time.s
2024-12-26 13:36:56 -05:00
Alexander Capehart
4f920e922d
musikr: add date added support w/cache
This allows me to replicate something resembling date added
support while reducing query load.
2024-12-26 10:33:50 -05:00
Alexander Capehart
da76a03298
Revert "musikr: add date added support"
This reverts commit ca6388b28d.
2024-12-26 09:04:18 -05:00
Alexander Capehart
ca6388b28d
musikr: add date added support
Through a new `Tracker` interface.

Tracker is kind of a generic name. It's set up in the case that I have
to wind up associating more post-extraction metadata with songs.
2024-12-24 15:24:29 -05:00
Alexander Capehart
c42ac644eb
musikr: compute uid on presong creation 2024-12-24 15:08:49 -05:00
Alexander Capehart
7768d98632
musikr.cover: refactor cover
Instead of using a weird sealed class, instead go for a
Cover/CoverCollection system instead that removes some implicit
design dependence in musikr.
2024-12-24 14:43:48 -05:00
Alexander Capehart
a24d102a00
app: reformat 2024-12-24 14:26:18 -05:00
Alexander Capehart
0cfd6ddb67
music: cleanup old cover revisions 2024-12-24 09:54:28 -05:00
Alexander Capehart
8409a93c4e
musikr: reformat 2024-12-23 20:53:01 -05:00
Alexander Capehart
9a7b970346
all: update todos 2024-12-23 17:13:55 -05:00
Alexander Capehart
258418578a
musikr.tag: parse artist sort name 2024-12-23 17:13:45 -05:00
Alexander Capehart
bdce83f047
musikr.tag: fix incorrect artist name placeholder 2024-12-23 17:04:12 -05:00
Alexander Capehart
75ca315b9b
musikr.tag: fix broken tag field 2024-12-23 17:01:07 -05:00
Alexander Capehart
518b80bdf2
musikr.metadata: add missing log header 2024-12-23 16:50:54 -05:00
Alexander Capehart
c379174ffe
musikr.metadata: wrap exceptions into log
Not ideal, but avoids a total catastrophic crash on failed metadata
extractions.
2024-12-23 16:48:32 -05:00
Alexander Capehart
b6bc065a4a
musikr.tag: parse mp4 fields 2024-12-23 16:46:56 -05:00
Alexander Capehart
6652e351cf
musikr.metadata: uppercase internal atoms 2024-12-23 16:46:35 -05:00
Alexander Capehart
6ccae5f0d2
musikr.metadata: fix mp4 parsing 2024-12-23 12:52:33 -05:00
Alexander Capehart
e56e290451
musikr: make nativeinputstream internal again 2024-12-23 11:27:34 -05:00
Alexander Capehart
77f97ef656
all: cleanup 2024-12-23 11:04:51 -05:00
Alexander Capehart
07118a5ff1
musikr: link correct taglib jni lib 2024-12-23 11:03:17 -05:00
Alexander Capehart
44696424a9
musikr: build taglib sequentially w/more threads
This is probably better since locality can be leveraged more.
2024-12-23 10:53:44 -05:00
Alexander Capehart
a888d09a2c
musikr: link private libraries
This is what the ffmpeg extension does, it probably does something
good.
2024-12-23 10:53:33 -05:00
Alexander Capehart
787a78f845
musikr: shrink end taglib jni size
Use some magic linker flags that @Tolriq found over in
https://github.com/taglib/taglib/issues/1212#issuecomment-2326456903
that somehow reduced linked so size by ~2mb.
2024-12-23 10:46:53 -05:00
Alexander Capehart
046a02de00
musikr: update readme 2024-12-23 10:05:48 -05:00
Alexander Capehart
b6cbf97df9
musikr: rename taglib jni cmake project 2024-12-23 10:05:33 -05:00
Alexander Capehart
6dd70af10c
musikr: fix more taglib jni mismatches 2024-12-23 10:04:41 -05:00
Alexander Capehart
6fd0bd411b
musikr: fix broken iostream jni integration 2024-12-23 09:59:23 -05:00
Alexander Capehart
6f8a960ee1
build: share desugaring version 2024-12-21 12:11:32 -05:00
Alexander Capehart
001db620e3
all: reformat 2024-12-21 11:52:28 -05:00
Alexander Capehart
9a38877c2e
musikr: hide cache database 2024-12-21 11:52:28 -05:00
Alexander Capehart
503a4854c3
musikr: hide playlist database 2024-12-21 11:52:28 -05:00
Alexander Capehart
a4cca0ca79
all: remove log.d calls 2024-12-21 11:52:28 -05:00
Weblate (bot)
ef502b6f4a
Translations update from Hosted Weblate (#921)
* Translated using Weblate (Azerbaijani)

Currently translated at 40.5% (124 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/az/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (312 of 312 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (307 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/

* Translated using Weblate (Portuguese)

Currently translated at 100.0% (307 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt/

* Translated using Weblate (Czech)

Currently translated at 100.0% (307 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (307 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

* Translated using Weblate (German)

Currently translated at 100.0% (307 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/

* Translated using Weblate (Finnish)

Currently translated at 97.3% (299 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/

* Translated using Weblate (Finnish)

Currently translated at 97.3% (299 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/

* Translated using Weblate (Finnish)

Currently translated at 100.0% (307 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/

* Added translation using Weblate (Latvian)

* Translated using Weblate (Latvian)

Currently translated at 98.1% (53 of 54 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/lv/

* Translated using Weblate (Finnish)

Currently translated at 100.0% (307 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/

* Added translation using Weblate (Tamil)

* Translated using Weblate (Tamil)

Currently translated at 100.0% (54 of 54 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/ta/

* Translated using Weblate (French)

Currently translated at 100.0% (307 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/

* Translated using Weblate (Tamil)

Currently translated at 100.0% (307 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ta/

* Translated using Weblate (Russian)

Currently translated at 98.0% (301 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/

* Translated using Weblate (Korean)

Currently translated at 100.0% (307 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/

* Translated using Weblate (Croatian)

Currently translated at 100.0% (307 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (307 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 100.0% (307 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/nb_NO/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (54 of 54 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/uk/

* Translated using Weblate (Norwegian Nynorsk)

Currently translated at 100.0% (307 of 307 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/nn/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (308 of 308 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (308 of 308 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/

* Translated using Weblate (Czech)

Currently translated at 100.0% (308 of 308 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (308 of 308 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (308 of 308 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

* Translated using Weblate (Spanish)

Currently translated at 99.6% (310 of 311 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (311 of 311 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (311 of 311 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/

* Translated using Weblate (German)

Currently translated at 100.0% (311 of 311 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (311 of 311 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

* Translated using Weblate (Czech)

Currently translated at 100.0% (311 of 311 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (311 of 311 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (311 of 311 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/

---------

Co-authored-by: Femini <nizamismidov4@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Cleverson Cândido <optimuspraimu@gmail.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Co-authored-by: qwerty287 <qwerty287@posteo.de>
Co-authored-by: Ricky Tigg <ricky.tigg@gmail.com>
Co-authored-by: Riku <riksu9000@gmail.com>
Co-authored-by: Languages add-on <noreply-addon-languages@weblate.org>
Co-authored-by: Deniss Krudd <denisskrudd@gmail.com>
Co-authored-by: தமிழ்நேரம் <anishprabu.t@gmail.com>
Co-authored-by: cwpute <collan+weblate@free.fr>
Co-authored-by: Evgeniy Khramov <65224669+thejenja@users.noreply.github.com>
Co-authored-by: 김인수 <simmon@nplob.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Максим Горпиніч <mgorpinic2005@gmail.com>
Co-authored-by: Sunniva Løvstad <weblate@turtle.garden>
Co-authored-by: santiago046 <comehere665@gmail.com>
2024-12-21 11:36:22 -05:00
Alexander Capehart
2ec3bbbe8c
musikr: hide unstable internals
Hypothetically I'd open these up into a broader API once I can confirm
they are safely extensible.
2024-12-21 11:35:14 -05:00
Alexander Capehart
b9c8933021
musikr: add readme 2024-12-20 22:47:46 -05:00
Alexander Capehart
45c3d3f4bc
build: silence dokka v2 warning 2024-12-20 22:28:20 -05:00
Alexander Capehart
c4a4b69cd1
musikr.pipeline: parallelize cache writes 2024-12-20 22:21:24 -05:00
Alexander Capehart
2842bd57b1
build: use dokka v2 2024-12-20 22:17:53 -05:00
Alexander Capehart
0f0b7a4a7d
all: reformat 2024-12-20 22:17:08 -05:00
Alexander Capehart
5ff949c49c
home: ratelimit textual progress updates
A la the notification, except on a shorter time internal since
it's more for efficiency rather than avoiding system rate limits.
2024-12-20 22:15:46 -05:00
Alexander Capehart
6bad9e719d
musikr.pipeline: parallelize cache reads 2024-12-20 22:12:01 -05:00
Alexander Capehart
9f68f59504
musikr.pipeline: disable extraction shuffling
No longer needed now that jpeg writes are extremely quick. Will
re-introduce in the case that I introduce webp parsing again.
2024-12-20 22:06:41 -05:00
Alexander Capehart
a598f39dea
musikr.cover: use jpeg for covers
Way faster to encode and the artifacts are minimal at 1kx1k and 100
compression.

Still not fully ideal, but webp is so flow to encode.
2024-12-20 22:05:51 -05:00
Alexander Capehart
1843986f75
music: implement revisioned covers 2024-12-20 21:57:16 -05:00
Alexander Capehart
8b69042288
Revert "musikr: bundle cover resolution with key"
This reverts commit 8cc939b58d.
2024-12-20 15:28:25 -05:00
Alexander Capehart
8cc939b58d
musikr: bundle cover resolution with key
This is a partial refactor, I'm still trying to find a good approach to
a revisionable system.
2024-12-20 12:41:14 -05:00
Alexander Capehart
249d2fad67
musikr.pipeline: shuffle songs to extract
This helps avoid the entire tag parsing flow from getting blocked up
by several tracks that are blocking trying to write a single cover.
2024-12-19 16:13:16 -05:00
Alexander Capehart
a77dd3ff7a
musikr.pipeline: redo extract pipeline
Try to separate opening FDs, extracting metadata, parsing tags/writing
covers, and cache writes.

This makes it slower, but now I know the bottleneck is covers. Gotta
figure out how to offload that work.
2024-12-17 20:31:04 -05:00
Alexander Capehart
7e8764d6d4
musikr.metadata: dont expose file name
Not needed.
2024-12-17 20:03:35 -05:00
Alexander Capehart
c431e90af8
musikr: output stack trace in pipeline error 2024-12-17 16:30:53 -05:00
Alexander Capehart
03ee8d299d
musikr: dont produce tag maps w/empty values 2024-12-17 16:27:25 -05:00
Alexander Capehart
7b1ccfc3fb
all: reformat 2024-12-17 16:05:13 -05:00
Alexander Capehart
acd4dab74c
musikr: include context in pipeline errors 2024-12-17 16:01:44 -05:00
Alexander Capehart
a1188b8d4b
music: introduce library revisions
Will be used to maintain image loading consistency even during loads.
2024-12-17 15:40:05 -05:00
Alexander Capehart
7df5c5973e
musikr: use dokka v2 2024-12-17 15:26:01 -05:00
Alexander Capehart
3fbb33e3e4
musikr: share more versions with app 2024-12-17 15:25:30 -05:00
Alexander Capehart
993060212b
build: temporarily disable app testing
Not needed right now.
2024-12-17 15:24:48 -05:00
Alexander Capehart
973c940042
actions: dont install clang 2024-12-17 15:24:24 -05:00
Alexander Capehart
7bd7b01a0b
build: bump media 2024-12-17 12:48:31 -05:00
Alexander Capehart
93da4a69a9
musikr: re-add playlist deletion 2024-12-17 12:19:36 -05:00
Alexander Capehart
7e45812411
all: reformat 2024-12-17 12:18:18 -05:00
Alexander Capehart
3ad2fd2fc0
musikr: fix playlist graphing 2024-12-17 12:17:28 -05:00
Alexander Capehart
b3a598c558
musikr: re-add playlist rewriting 2024-12-17 12:12:09 -05:00
Alexander Capehart
744097694f
musikr: re-add playlist adding 2024-12-17 12:11:20 -05:00
Alexander Capehart
f4822a4e40
musikr: re-add playlist renaming 2024-12-17 12:04:24 -05:00
Alexander Capehart
9f657adf94
musikr: re-add playlist creation 2024-12-17 11:48:17 -05:00
Alexander Capehart
bdfd9d6e23
musikr: move storage/interpretation dependence to construction
This makes some testing and certain code more ergonomic.
2024-12-17 11:45:04 -05:00
Alexander Capehart
f3913b148a
all: reformat 2024-12-17 11:27:25 -05:00
Alexander Capehart
8bbb7497a6
musikr: fix stuck evaluate step 2024-12-17 11:27:14 -05:00
Alexander Capehart
6850a3443f
detail: reimplement song details 2024-12-17 11:26:09 -05:00
Alexander Capehart
50b7c24c03
musikr: expose bitrate and sample rate 2024-12-17 10:42:54 -05:00
Alexander Capehart
880967f8be
all: fix formatting 2024-12-16 20:47:17 -05:00
Alexander Capehart
7fab7f7eeb
musikr: add full playlist evaluation 2024-12-16 20:45:26 -05:00
Alexander Capehart
3d94ab67cf
musikr: re-implement playlist graphing 2024-12-16 20:13:08 -05:00
Alexander Capehart
a50b55cf70
actions: install clang
So spotless can use clang-format.
2024-12-16 19:12:54 -05:00
Alexander Capehart
11a4d6a720
name: fix name token constructor 2024-12-16 19:07:50 -05:00
Alexander Capehart
ac1c31cacb
actions: improve format/test checks
- Only test musikr since it's the only thing with tests
- Check formatting rather than autoformatting on build
2024-12-16 19:02:31 -05:00
Alexander Capehart
ee0c643115
all: reformat 2024-12-16 19:01:22 -05:00
Alexander Capehart
ad183bdbfd
music: add musikr injects 2024-12-16 19:00:22 -05:00
Alexander Capehart
d0845ef325
playback: move pre-amp from music back to rg 2024-12-16 18:47:27 -05:00
Alexander Capehart
b6f6213ac4
all: reformat 2024-12-16 18:46:54 -05:00
Alexander Capehart
6e3b03d4c6
musikr: re-implement playlist loading 2024-12-16 18:46:06 -05:00
Alexander Capehart
50bfe9926b
musikr.model: use genre core 2024-12-16 15:50:10 -05:00
Alexander Capehart
4421f4f56d
musikr.graph: dont simplify size-1 clusters
Creates very hard to trace bugs.
2024-12-16 15:47:59 -05:00
Alexander Capehart
9d1978850b
musikr: update classpaths in native code 2024-12-16 15:20:46 -05:00
Alexander Capehart
00520f7fda
musikr: api fixes 2024-12-16 15:15:30 -05:00
Alexander Capehart
5a65a6aa25
musikr: additional api cleanup 2024-12-16 14:49:24 -05:00
Alexander Capehart
47d5184e8d
build: add dokka
Just to test API surface in the future.
2024-12-16 14:41:45 -05:00
Alexander Capehart
0d5abb6407
musikr: cleanup api 2024-12-16 14:33:31 -05:00
Alexander Capehart
14355a1005
musikr: extract out shared parse fns
Into the util module
2024-12-16 13:41:57 -05:00
Alexander Capehart
4d0465e012
info: update sponsors 2024-12-16 13:39:48 -05:00
Alexander Capehart
ed102d3414
all: format 2024-12-16 13:34:49 -05:00
Alexander Capehart
18c5b3618c
build: fix spotless config
- Missing NOTICE
- CPP format was going too deep
2024-12-16 13:34:30 -05:00
Alexander Capehart
d4d00249df
musikr: move tag tests away from auxio 2024-12-16 13:27:45 -05:00
Alexander Capehart
71667f378d
musikr: merge ktaglib into musikr.metadata
No longer makes sense as an independent module.
2024-12-16 13:18:13 -05:00
Alexander Capehart
ae44abc35a
musikr: reduce taglib build parallelism
Likely unhealthy to run maximum thread count 4x over
2024-12-16 13:15:47 -05:00
Alexander Capehart
e908d0e102
all: break off musikr 2024-12-16 13:09:08 -05:00
Alexander Capehart
f33377cf26
musikr: decouple releasetype from auxio 2024-12-16 12:11:28 -05:00
Alexander Capehart
479dca4452
musikr: decouple m3u from auxio 2024-12-14 15:58:48 -05:00
Alexander Capehart
31e092a649
musikr: indicate song name always known 2024-12-14 15:58:38 -05:00
Alexander Capehart
b5657f0202
musikr: decouple volume from auxio 2024-12-14 15:52:58 -05:00
Alexander Capehart
e9c15bfbef
musikr: decouple date range from auxio 2024-12-14 15:51:52 -05:00
Alexander Capehart
cb84b2db17
musikr: decouple disc from auxio 2024-12-14 15:50:14 -05:00
Alexander Capehart
e3146647d3
musikr: decouple date from auxio 2024-12-14 15:48:05 -05:00
Alexander Capehart
c5cd404393
musikr: decouple name from auxio 2024-12-14 13:41:38 -07:00
Alexander Capehart
de1c091517
musikr: remove trivial auxio dependence
There's still some thorny resource use left over, but this is a good
starting point to start breaking off musikr from auxio.
2024-12-13 20:08:58 -07:00
Alexander Capehart
3da9e6c5b3
musikr: remove musictype auxio dependency 2024-12-13 19:50:45 -07:00
Alexander Capehart
c70c27a7b4
musikr: standardize internal song data structure 2024-12-13 19:44:02 -07:00
Alexander Capehart
9ab4dc5595
musikr: replace mimetype w/format
First property now derived from taglib.
2024-12-13 19:23:42 -07:00
Alexander Capehart
e16b23f34e
musikr: remove di 2024-12-13 18:02:39 -07:00
Alexander Capehart
a2498db6e5
musikr: use uppercase tag names
This reduces the amount of string processing I need to do in
ktaglib.
2024-12-13 16:20:46 -07:00
Alexander Capehart
65151e006f
musikr: start using ktaglib 2024-12-13 13:06:19 -07:00
Alexander Capehart
2f98d67855
ktaglib: fix tag mapping
- TagLib apparently bundles description with a TXXX frame's field values.
- TagLib doesn't normalize to lowercase like Auxio does (Will change this
in the future to be uppercase instead to save on re-allocs)
2024-12-13 13:04:49 -07:00
Alexander Capehart
93a602b592
all: misc cleanup 2024-12-13 11:35:24 -07:00
Alexander Capehart
993dbbf8c1
musikr: fix missing import 2024-12-13 11:35:08 -07:00
Alexander Capehart
a593f2874d
music: fix settings update insanity
For some reason StringSet updates will simply not go to the listener.
Despite it working just fine in previous versions.

I have to derialize all the location to a string and use that.
2024-12-13 11:34:16 -07:00
Alexander Capehart
76eb98c3af
musikr: fix cover file reads
Turns out they were coming from the wrong place.
2024-12-13 11:15:23 -07:00
Alexander Capehart
5fae4601de
music: fix broken location updates 2024-12-13 10:54:14 -07:00
Alexander Capehart
59df1c3d57
musikr: start unwinding di use
Musikr is eventually going to be an entirely independent gradle module
with a DI-agnostic API, start removing some of the directives (but not
all since some are kinda thorny to untangle)
2024-12-13 08:32:30 -07:00
Alexander Capehart
34217696c2
ktaglib: build with picture data 2024-12-12 19:22:54 -07:00
Alexander Capehart
a60239c6f7
ktaglib: implement metadata builder 2024-12-12 19:22:35 -07:00
Alexander Capehart
29f82c0963
ktaglib: implement tag parsing scaffold 2024-12-12 18:39:50 -07:00
Alexander Capehart
44de732247
ktaglib: improve jvminputstream mem use 2024-12-12 18:39:35 -07:00
Alexander Capehart
34be5fb2a5
ktaglib: add missing source files 2024-12-12 18:39:24 -07:00
Alexander Capehart
5042d3f5f2
ktaglib: introduce tag map data structure 2024-12-12 18:39:11 -07:00
Alexander Capehart
be54ee9c18
ktaglib: implement iostream/file shim 2024-12-12 14:26:17 -07:00
Alexander Capehart
55e77707ea
ktaglib: scaffold jni impl 2024-12-12 13:17:03 -07:00
Alexander Capehart
7640292d7a
ktaglib: fix package bugs 2024-12-12 12:53:27 -07:00
Alexander Capehart
8c865fb581
build: enable parallel builds 2024-12-12 12:41:30 -07:00
Alexander Capehart
1289922cd9
ktaglib: fix more build issues 2024-12-12 12:36:57 -07:00
Alexander Capehart
c7dfae5262
ktaglib: use common shell only in build 2024-12-12 12:29:23 -07:00
Alexander Capehart
a5d7d47aba
ktaglib: fix more build issues 2024-12-12 12:23:18 -07:00
Alexander Capehart
abb547aba3
ktaglib: fix package namespace 2024-12-12 12:14:29 -07:00
Alexander Capehart
a85acceed6
ktaglib: autobuild taglib on gradle build 2024-12-12 12:11:18 -07:00
Alexander Capehart
1c85dc96e0
ktaglib: import taglib into project
This is getting out of hand. Anything for speed.
2024-12-12 12:05:22 -07:00
Alexander Capehart
d3f75439fc
musikr: create required dirs for cover files 2024-12-11 17:39:34 -07:00
Alexander Capehart
63193809b0
image: remove unused null cover branch 2024-12-11 17:39:17 -07:00
Alexander Capehart
88f43a7906
musikr: fix unpopulated presong cover 2024-12-11 17:39:06 -07:00
Alexander Capehart
6d85f43304
image: connect cover back up 2024-12-11 17:38:42 -07:00
Alexander Capehart
0ce3a11f82
musikr: re-connect cover to model 2024-12-11 17:16:34 -07:00
Alexander Capehart
cf69b27134
musikr: add cover to evaluation process 2024-12-11 17:12:29 -07:00
Alexander Capehart
8b4672ea50
musikr: cleanup cache db 2024-12-11 17:10:12 -07:00
Alexander Capehart
f13c1e364b
musikr: add cover key to cache 2024-12-11 17:08:35 -07:00
Alexander Capehart
42390f4b3f
music: move cover parsing to indexing
This drastically slows music loading, but my hope is that in practice
most of the slowdown is actually in ExoPlayer's metadata extractor and
if I switch off of that things will actually improve. Maybe.
2024-12-11 16:55:37 -07:00
Alexander Capehart
b53b7a0c6a
all: temp fix build issues 2024-12-11 13:18:22 -07:00
Alexander Capehart
530d8cc2b5
musikr: remove di requirement from tagcache 2024-12-11 06:58:42 -07:00
Alexander Capehart
45ead8253a
music: prepare new cover system 2024-12-10 07:02:37 -07:00
Alexander Capehart
8adda19d1a
musikr: add new storage config
Allowed TagCache to be configured alongside a new StoredCovers
(to be implemented later)
2024-12-09 16:06:53 -07:00
Alexander Capehart
df1faa11e4
musikr: fix various loading bugs 2024-12-09 13:06:25 -07:00
Alexander Capehart
2592aca4bf
musikr: refactor root module 2024-12-09 09:55:44 -07:00
Alexander Capehart
3528392f95
musikr: rename indexer to musikr 2024-12-09 08:58:45 -07:00
Alexander Capehart
0f8294bf43
musikr: refactor fs
- Move MimeType back into fs
- Move DeviceFiles into a new query module
2024-12-09 08:55:48 -07:00
Alexander Capehart
501c79d23c
musikr: refactor model 2024-12-09 08:44:56 -07:00
Alexander Capehart
1d0ad641d5
all: fix various build/test issues 2024-12-09 08:13:47 -07:00
Alexander Capehart
efceefc221
musikr: break apart storageutil 2024-12-09 07:17:40 -07:00
Alexander Capehart
ced2adb2c6
all: cleanup 2024-12-09 07:15:32 -07:00
Alexander Capehart
c270759dec
musikr: improve music location creation 2024-12-07 17:19:30 -07:00
Alexander Capehart
2a38d1ae8d
musikr: break apart Fs.kt 2024-12-07 11:49:43 -07:00
Alexander Capehart
3eaa96ffda
music: split off music location into musikr 2024-12-07 11:46:38 -07:00
Alexander Capehart
abeabcb8df
musikr: split off from auxio 2024-12-07 09:51:16 -07:00
Alexander Capehart
75c2d7cd16
musikr: re-add loading progress 2024-12-07 08:41:32 -07:00
Alexander Capehart
970fdb2a8d
musikr: introduce new graphing system
This does all the required simpification steps as before, but now
creates mutual edges between parent and child items that removes
the finicky finalization logic in models.
2024-12-07 08:41:28 -07:00
Alexander Capehart
7f7ee94f45
musikr: restructure loader into pipeline 2024-12-04 15:08:49 -07:00
Alexander Capehart
7582c8c9cf
music: reorganize metadata/tag/model structure 2024-12-02 14:22:38 -07:00
Alexander Capehart
59652b2f9b
image: collapse cache into musikr
(Will be integrated into loader later)
2024-11-30 19:21:29 -07:00
Alexander Capehart
49aa3c2891
music: collapse external into musikr 2024-11-30 17:28:33 -07:00
Alexander Capehart
43c05e7096
music: collapse metadata into musikr 2024-11-30 17:25:58 -07:00
Alexander Capehart
dfff01bd28
music: move other metadata into model 2024-11-30 17:24:17 -07:00
Alexander Capehart
523d3cdd30
musikr: flatten modules 2024-11-30 17:05:13 -07:00
Alexander Capehart
86a77bc19b
music: break off stack into musikr
Will become it's own separate module later.
2024-11-30 11:37:18 -07:00
Alexander Capehart
e647c31c56
music: use unlimited buffer for caching 2024-11-29 16:32:17 -07:00
Alexander Capehart
a3da28fb84
music: enable tag caching 2024-11-29 16:29:00 -07:00
Alexander Capehart
a22e972bd3
image: refactor transcoding
- Don't transcode into memory
- Make AppFiles (now CoverFiles) handle transcoding
- Don't bother transcoding if no work needs to be done
2024-11-29 14:58:50 -07:00
Alexander Capehart
6b8b147721
image: improve cover cache design
- Don't send around InputStreams when really we are extracting ByteArray
- Hash with MD5, which should be a good enough tm hash even if easily
collideable
- Split off cover identification into another object
2024-11-29 13:28:05 -07:00
Alexander Capehart
e061f7cb26
image: further improve cover caching
- Don't rewrite files if they already exist
- Use webp compression
- Downsize cover images to save memory
2024-11-29 13:17:06 -07:00
Alexander Capehart
c74c62d9b3
image: fix bitmapprovider covers 2024-11-29 13:16:43 -07:00
Alexander Capehart
3dbe06c0bc
music: use aosp covers by default
Less good, but it's also far more memory efficient.
2024-11-29 10:12:10 -07:00
Alexander Capehart
f57ee549f1
image: cleanup cache 2024-11-29 10:12:03 -07:00
Alexander Capehart
ab442f99c1
image: remove dead code 2024-11-29 10:06:26 -07:00
Alexander Capehart
1a3fe7c075
image: refactor module
- All old extractor stuff is now a module called coil
- Moved Cover out of the coil module
2024-11-29 09:59:31 -07:00
Alexander Capehart
94a580aaed
build: update media 2024-11-29 09:50:27 -07:00
Alexander Capehart
b832ac8639
music: make caching thread safe 2024-11-29 09:50:09 -07:00
Alexander Capehart
c3f9f0d80e
image: use cover retriever in app 2024-11-29 09:49:45 -07:00
Alexander Capehart
ddfe10b869
home: fix no music indicator 2024-11-29 09:48:26 -07:00
Alexander Capehart
7a7774a4db
image: implement extractors and new cover data 2024-11-27 20:12:11 -07:00
Alexander Capehart
37697abfce
music: introduce new image loader cache
This will be used with the new SAF-loaded music files to show covers.
2024-11-27 17:48:16 -07:00
Alexander Capehart
b30aba4bdf
music: add last modified to song 2024-11-27 17:47:42 -07:00
Alexander Capehart
a30e6db71d
music: fix in-process grouping problems
Parent objects cannot process anything related to eachother until
finalize without causing set issues, fix that
2024-11-27 15:20:03 -07:00
Alexander Capehart
1b295934e0
music: use unlimited buffering in loader 2024-11-27 15:18:55 -07:00
Alexander Capehart
d52e301751
music: try to fix extractor thread starvation 2024-11-27 15:18:34 -07:00
Alexander Capehart
ffaff6f08e
settings: fix broken count icon tints 2024-11-27 09:49:43 -07:00
Alexander Capehart
c9d370048f
playback: fix broken repeat one tint 2024-11-27 09:49:33 -07:00
Alexander Capehart
b31562e250
home: make no music indicator follow edge-to-edge 2024-11-27 09:47:09 -07:00
Alexander Capehart
e0bbb88e92
music: only compute song uid once 2024-11-27 09:46:59 -07:00
Alexander Capehart
dd3b411beb
home: fix no music indicator display 2024-11-27 09:46:46 -07:00
Alexander Capehart
ae449ded45
music: emulate old music loading process 2024-11-27 09:40:59 -07:00
Alexander Capehart
c74b744aec
music: temp populate cover field
Again, will reimpl later
2024-11-26 20:19:08 -07:00
Alexander Capehart
c87ff7bb92
music: grant correct tree uri perms
Will refactor later just want stuff to work
2024-11-26 20:16:06 -07:00
Alexander Capehart
dba11a61b4
music: indicate interpreted song artists
Kind of stupid, but since I don't really have a good streaming
representation of interpreting progress yet this is what worksbest.
2024-11-26 20:14:36 -07:00
Alexander Capehart
1962fbe70a
music: emit indexing completion at end 2024-11-26 20:14:18 -07:00
Alexander Capehart
cc9bb167c4
music: fix device files uris 2024-11-26 20:13:53 -07:00
Alexander Capehart
ec19808cf1
music: use old chunked retriever in extractor 2024-11-26 20:13:24 -07:00
Alexander Capehart
144da8a3b5
music: temp strip down explorer & fix threading
Mostly for continued debugging
2024-11-26 20:13:04 -07:00
Alexander Capehart
ba5f51dfe6
music: init loading progress at start 2024-11-26 20:12:44 -07:00
Alexander Capehart
6e4e818fd4
music: implement music locations dialog 2024-11-26 15:20:51 -07:00
Alexander Capehart
38ed432555
home: reflect no music state in tabs 2024-11-26 14:53:31 -07:00
Alexander Capehart
4618996fc5
music: integrate new loader into services 2024-11-26 13:55:37 -07:00
Alexander Capehart
b0c6dd2b74
music: improve indexing progress 2024-11-26 13:11:08 -07:00
Alexander Capehart
0ba5ddce51
music: re-add library find functionality 2024-11-26 10:08:14 -07:00
Alexander Capehart
9d9f810356
music: re-add song deduplication 2024-11-26 10:05:17 -07:00
Alexander Capehart
3bf80073f4
music: fix indexing updates 2024-11-26 10:05:02 -07:00
Alexander Capehart
2f9ced2ac3
music: re-add event handling
Kinda scuffed, will probably split into low-level events
and do the MusicRepository interpret step in Indexer.
2024-11-26 09:54:52 -07:00
Alexander Capehart
ba29905aa6
music: connect new loader to rest of app 2024-11-26 09:35:14 -07:00
Alexander Capehart
e3d6644634
music: implement album linking 2024-11-25 20:24:21 -07:00
Alexander Capehart
608e249a87
music: fix extractor module 2024-11-25 20:24:13 -07:00
Alexander Capehart
9a990096da
music: fix genre linker issues 2024-11-25 20:24:06 -07:00
Alexander Capehart
c7f4f842f3
music: implement artist linking 2024-11-25 20:23:51 -07:00
Alexander Capehart
db391da4b8
music: implement genre linking 2024-11-25 16:34:02 -07:00
Alexander Capehart
d633a6b9f1
music: refactor tag extraction
- Include MediaMetadataRetriever use
- Separate interpretation into extension functions
- AudioFile is now immutable
- Removed any type of progressive AudioFile preparation
(like in the old loader)
2024-11-25 12:55:17 -07:00
Alexander Capehart
73ff7e2c7f
music: connect stored playlists to loader 2024-11-25 10:19:16 -07:00
Alexander Capehart
c4f4797028
music: build saf loader playlist boilerplate 2024-11-23 17:18:02 -07:00
Alexander Capehart
ba9ab5a445
music: refactor new stack 2024-11-23 10:02:56 -07:00
Alexander Capehart
517da485e1
Introduce Interpreter
This is utterly broken and mostly a starting point for future
refactoring.
2024-11-23 10:02:56 -07:00
Weblate (bot)
c022be6e4d
Translations update from Hosted Weblate (#903)
* Translated using Weblate (Italian)

Currently translated at 99.6% (302 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (54 of 54 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/it/

* Translated using Weblate (Albanian)

Currently translated at 23.1% (70 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sq/

* Translated using Weblate (Russian)

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/

* Translated using Weblate (Belarusian)

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/

* Translated using Weblate (Albanian)

Currently translated at 27.3% (83 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sq/

* Translated using Weblate (Albanian)

Currently translated at 40.2% (122 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sq/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (306 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (306 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

* Translated using Weblate (Czech)

Currently translated at 100.0% (306 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/

* Translated using Weblate (German)

Currently translated at 100.0% (306 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (306 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/

* Translated using Weblate (Azerbaijani)

Currently translated at 98.1% (53 of 54 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/az/

* Translated using Weblate (Albanian)

Currently translated at 65.6% (201 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sq/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (305 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/

* Translated using Weblate (Azerbaijani)

Currently translated at 20.5% (63 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/az/

* Translated using Weblate (Albanian)

Currently translated at 67.3% (206 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sq/

* Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/

* Translated using Weblate (French)

Currently translated at 99.0% (303 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/

* Translated using Weblate (Albanian)

Currently translated at 100.0% (306 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sq/

* Translated using Weblate (Azerbaijani)

Currently translated at 32.6% (100 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/az/

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (306 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/bg/

* Translated using Weblate (Polish)

Currently translated at 100.0% (306 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pl/

* Translated using Weblate (Azerbaijani)

Currently translated at 37.9% (116 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/az/

* Translated using Weblate (Polish)

Currently translated at 100.0% (54 of 54 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/pl/

* Translated using Weblate (French)

Currently translated at 99.6% (305 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/

* Translated using Weblate (Japanese)

Currently translated at 93.1% (285 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ja/

* Translated using Weblate (Portuguese)

Currently translated at 100.0% (306 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 97.7% (299 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/nb_NO/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 98.0% (300 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/nb_NO/

* Added translation using Weblate (Norwegian Nynorsk)

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 98.0% (300 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/nb_NO/

* Translated using Weblate (Norwegian Nynorsk)

Currently translated at 37.9% (116 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/nn/

* Translated using Weblate (Croatian)

Currently translated at 100.0% (306 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 98.0% (300 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/nb_NO/

* Translated using Weblate (Norwegian Nynorsk)

Currently translated at 100.0% (306 of 306 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/nn/

* Translated using Weblate (Filipino)

Currently translated at 98.1% (53 of 54 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/fil/

---------

Co-authored-by: Lam <lambdamutau@proton.me>
Co-authored-by: D <dici.handy@gmail.com>
Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: qwerty287 <qwerty287@posteo.de>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Femini <nizamismidov4@gmail.com>
Co-authored-by: Lucas Lopes <weblate.dusk390@slmail.me>
Co-authored-by: trunars <trunars@gmail.com>
Co-authored-by: Oliwier Czerwiński <oliwier.czerwi@proton.me>
Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Co-authored-by: Meteor2 <ryumeteor175@gmail.com>
Co-authored-by: Cleverson Cândido <optimuspraimu@gmail.com>
Co-authored-by: Sunniva Løvstad <weblate@turtle.garden>
Co-authored-by: Languages add-on <noreply-addon-languages@weblate.org>
Co-authored-by: sunniva <sunniva@users.noreply.hosted.weblate.org>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: Nathan Paqueen <natesisgreatatpotato@gmail.com>
2024-11-20 18:07:02 -07:00
Alexander Capehart
806fabc89a
music: rename indexer -> indexing holder 2024-11-19 17:52:44 -07:00
Alexander Capehart
556c5d5e0a
all: eliminate refactor errors 2024-11-19 17:50:56 -07:00
Alexander Capehart
f76eafc9d4
music: connect saf indexer to libraries
Largely temporary, to be replaced with Interpreter
2024-11-19 15:17:05 -07:00
Alexander Capehart
e51b2817e9
music: merge metadata into stack 2024-11-19 14:44:37 -07:00
Alexander Capehart
cdc5a37bfa
music: merge fs into stack 2024-11-19 14:42:12 -07:00
Alexander Capehart
b651a3be03
music: refactor saf loader into new module 2024-11-19 13:37:57 -07:00
Alexander Capehart
01a5e87a77
music: introduce saf tag cache 2024-11-19 13:19:42 -07:00
Alexander Capehart
53d0dbd0cb
music: introduce saf-based tag extractor 2024-11-19 10:16:29 -07:00
Alexander Capehart
cadd2d1231
music: use saf fields in raw song 2024-11-19 10:16:09 -07:00
Alexander Capehart
5b447f7efb
music: include path with loaded saf files 2024-11-15 12:11:57 -07:00
Alexander Capehart
300f26739d
music: introduce saf explorer
No functionality right now
2024-11-13 10:09:50 -07:00
Alexander Capehart
4d27c444de
all: reformat 2024-11-13 10:04:58 -07:00
Alexander Capehart
f783a9c32f
image: use coil3 2024-11-11 11:51:26 -07:00
Alexander Capehart
85bd1f0062
detail: fix play icon alignment 2024-11-07 23:52:50 -07:00
Alexander Capehart
d6e09dcf2a
music: new fuzzy grouper
New fuzzy grouper that:
1. Does not eagerly group by MBID unless fully tagged
2. Does not eagerly group by artist by default
2024-11-07 23:25:17 -07:00
Alexander Capehart
c2d18b77f6
build: downgrade androidx fragment
Again, more predictive back issues.
2024-11-07 20:59:31 -07:00
Alexander Capehart
fe6c07a342
recycler: redesign fast scroller
- Use new "bump" design
- Base off fundamental RV primitives over custom item
calculations
- Make possible to use by non-home views
2024-11-07 20:52:48 -07:00
Alexander Capehart
8ec61c9388
list: prevent recycler scroll jumping in main 2024-11-07 13:48:54 -07:00
Alexander Capehart
1d19d00798
detail: add icons to play/shuffle 2024-11-07 13:42:55 -07:00
Alexander Capehart
211b815a20
ui: handle round mode again 2024-11-07 13:38:54 -07:00
Alexander Capehart
f25c98aa7e
build: bump deps 2024-11-07 13:12:05 -07:00
Alexander Capehart
2db23369e3
ui: update themes to m3.1 2024-11-07 13:09:37 -07:00
Alexander Capehart
ad760d4da1
info: update changelog 2024-11-07 13:08:59 -07:00
Alexander Capehart
075f6c3da3
build: update deps 2024-10-31 15:38:51 -06:00
Alexander Capehart
d06dd59386
about: add feedback options 2024-10-31 15:31:23 -06:00
Weblate (bot)
022fe9ae1b
Translations update from Hosted Weblate (#874)
* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (50 of 50 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/pt_BR/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (50 of 50 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/et/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

* Translated using Weblate (German)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/

* Translated using Weblate (Finnish)

Currently translated at 98.6% (298 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/

* Translated using Weblate (French)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/

* Translated using Weblate (Portuguese (Portugal))

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_PT/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/

* Translated using Weblate (Czech)

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/

* Translated using Weblate (German)

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/bg/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/bg/

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/bg/

* Translated using Weblate (Azerbaijani)

Currently translated at 14.8% (45 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/az/

* Translated using Weblate (Korean)

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/

* Translated using Weblate (Lithuanian)

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/

* Added translation using Weblate (Albanian)

* Translated using Weblate (Finnish)

Currently translated at 98.3% (298 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/

* Translated using Weblate (Albanian)

Currently translated at 0.9% (3 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sq/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (303 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/

* Translated using Weblate (Albanian)

Currently translated at 100.0% (54 of 54 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/sq/

* Translated using Weblate (Albanian)

Currently translated at 8.9% (27 of 303 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sq/

---------

Co-authored-by: santiago046 <comehere665@gmail.com>
Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Co-authored-by: min7-i <min7-i@users.noreply.hosted.weblate.org>
Co-authored-by: Riku <riksu9000@gmail.com>
Co-authored-by: Wydow <wydow@protonmail.com>
Co-authored-by: João Palmeiro <joaommpalmeiro@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: qwerty287 <qwerty287@posteo.de>
Co-authored-by: trunars <trunars@gmail.com>
Co-authored-by: Femini <nizamismidov4@gmail.com>
Co-authored-by: Yurical <yurical1@outlook.com>
Co-authored-by: Vaclovas Intas <Gateway_31@protonmail.com>
Co-authored-by: Languages add-on <noreply-addon-languages@weblate.org>
Co-authored-by: Jiri Grönroos <jiri.gronroos@iki.fi>
Co-authored-by: D <dici.handy@gmail.com>
Co-authored-by: Lucas Lopes <weblate.dusk390@slmail.me>
2024-10-28 07:19:50 -06:00
Alexander Capehart
6406b49501
build: upgrade gradle/agp 2024-10-24 09:13:37 -06:00
Alexander Capehart
d7f3c58fd9
detail: fix broken playing state updates 2024-10-23 10:05:23 -06:00
Alexander Capehart
82ddd3a24e
widget: disable android 15 preview
Seemingly causing reboots on my device, don't wanna risk it.
2024-10-23 09:08:11 -06:00
Alexander Capehart
3753b5f0cc
info: update changelog 2024-10-23 09:07:45 -06:00
Alexander Capehart
0ed7938be9
Merge branch 'master' into dev 2024-10-23 08:55:45 -06:00
Alexander Capehart
018e142ee9
list: fix sort dialog allowing invalid sorts
If you changed the mode but disabled the direction, you would wind up
with an outright invalid sort that you could still save. Fix that.
2024-10-22 22:03:16 -06:00
Alexander Capehart
97b0a8aa68
ui: haromize bottom sheet radii w/cover radii 2024-10-22 21:57:14 -06:00
Alexander Capehart
bd685f1f9c
ui: change materialfader anim sepcs
Probably a little more in-line w/the docs.
2024-10-22 21:45:01 -06:00
Alexander Capehart
bfeae6a5a9
Merge pull request #899 from OxygenCobalt/hotfixes
v3.6.3
2024-10-21 10:43:49 -06:00
Alexander Capehart
5751725e8e
build: bump to 3.6.3 2024-10-21 10:24:39 -06:00
Alexander Capehart
b0af681390
playback: fix broken stateholder lifecycle
- Broken ReplayGain setup
- Wasn't releasing playback settings
2024-10-21 09:46:43 -06:00
Alexander Capehart
47fa41715d
detail: fix empty sections having headers 2024-10-21 09:42:00 -06:00
Alexander Capehart
147f7f426c
detail: fix crash on artists w/appearances 2024-10-21 09:41:51 -06:00
Alexander Capehart
64fbd0acbf
home: re-add removed hcollaborator hiding 2024-10-21 09:35:54 -06:00
Alexander Capehart
89110c2798
image: new cover selection animation 2024-10-19 12:58:58 -06:00
Alexander Capehart
59fd4b5e18
playback: make repeat/shuffle on icons thicker 2024-10-19 12:29:38 -06:00
Alexander Capehart
22ce9988c8
ui: start moving to pre-packaged anims 2024-10-19 12:26:42 -06:00
Alexander Capehart
50829a54d3
detail: fix extra divider on playlist edit 2024-10-19 12:25:46 -06:00
Alexander Capehart
de36f26394
build: bump media 2024-10-18 16:46:26 -06:00
Alexander Capehart
bba4ae81e7
ui: phase out custom track color 2024-10-18 16:37:49 -06:00
Alexander Capehart
4befe1910f
info: update changelog 2024-10-18 16:36:43 -06:00
Alexander Capehart
64354f7f6e
widget: add live preview for android 15 2024-10-18 16:35:35 -06:00
Alexander Capehart
15121d28f6
ui: fix broken toolbar anims 2024-10-18 16:19:38 -06:00
Alexander Capehart
9a01fe471e
detail: fix squished disc headers 2024-10-18 16:15:10 -06:00
Alexander Capehart
0f4702c4dd
all: fix logging & anim unification
Can't bisect this without spending way too much time on it.
2024-10-18 16:10:08 -06:00
Alexander Capehart
7dfaea3a4b
all: cleanup 2024-10-18 15:41:06 -06:00
Alexander Capehart
22ddda4e60
all: reformat 2024-10-18 08:44:03 -06:00
Alexander Capehart
c1514d6029
log: re-add copyleft notice 2024-10-18 08:43:53 -06:00
Alexander Capehart
f7488f7b0d
playback: fix deprecated constructors 2024-10-17 21:08:41 -06:00
Alexander Capehart
a46fa85d67
build: update to kotlin 2.0.0 2024-10-17 21:07:15 -06:00
Alexander Capehart
fbd94f1a21
all: fix invalid logs
These are leftover debug logs
2024-10-17 21:06:35 -06:00
Alexander Capehart
745bff268f
build: move buildconfig to recipe 2024-10-17 20:37:22 -06:00
Alexander Capehart
6d72240336
all: fully use timber for logging 2024-10-17 20:15:57 -06:00
Alexander Capehart
a9a35c8055
build: update deps
nav -> 2.8.3
lifecycle -> 2.8.6
activity -> 1.9.3
2024-10-17 19:45:47 -06:00
Alexander Capehart
9883cf1c91
list: tweak header/divider object hierarchy
Make a new generic Header/Divider superclass that all
headers derive.

This allows disc headers to be recognized generically
in places like the grid layout manager.
2024-10-17 09:57:47 -06:00
Alexander Capehart
1ee5645780
detail: continue scrolling even after toolbar collapses 2024-10-17 09:44:51 -06:00
Alexander Capehart
6c9f170afc
Merge branch 'master' into dev 2024-10-16 14:37:18 -06:00
Alexander Capehart
03be2ef028
Merge pull request #890 from OxygenCobalt/hotfixes
v3.6.2
2024-10-15 10:32:00 -06:00
Alexander Capehart
ea405b1cb8
build: fix mismatched ffmpeg/app ndk versions 2024-10-15 10:02:30 -06:00
Alexander Capehart
4ef021f664
build: bump to 3.6.2 2024-10-15 09:42:44 -06:00
Alexander Capehart
b4d6c0a611
playback: fix broken notification close action 2024-10-15 09:37:48 -06:00
Alexander Capehart
d1e8cc3320
detail: fix playlist edit header update 2024-10-14 20:19:35 -06:00
Alexander Capehart
3ff681b870
Merge branch 'master' into dev 2024-10-14 20:19:23 -06:00
Alexander Capehart
b2f10ea5ef
Merge pull request #886 from OxygenCobalt/hotfixes
v3.6.1
2024-10-14 20:13:49 -06:00
Alexander Capehart
4a6273e2da
build: bump to 3.6.1 2024-10-14 19:54:15 -06:00
Alexander Capehart
9b4e9b30b2
service: fix release memory leaks 2024-10-14 19:51:52 -06:00
Alexander Capehart
caa2e02aff
detail: correctly reset edited playlist 2024-10-14 19:34:51 -06:00
Alexander Capehart
3898646691
detail: fix missing edit header in playlists 2024-10-14 18:35:20 -06:00
Alexander Capehart
226f078aa4
service: attach after init 2024-10-14 18:34:28 -06:00
Alexander Capehart
97faa3f20e
detail: improve disc header design 2024-10-14 18:25:20 -06:00
Alexander Capehart
190abd5588
all: fix merge regressions 2024-10-14 14:52:21 -06:00
Alexander Capehart
d2524a0b3a
Merge branch 'dev' of github.com:OxygenCobalt/Auxio into dev 2024-10-14 14:36:42 -06:00
Alexander Capehart
d540d6f14c
build: initial android 15 upgrade 2024-10-14 14:35:33 -06:00
Alexander Capehart
8d767a0aac
Merge branch 'master' into dev 2024-10-14 14:33:24 -06:00
Alexander Capehart
18f96ed3ec
Merge pull request #885 from OxygenCobalt/v3.6.0
V3.6.0
2024-10-14 13:35:17 -06:00
Alexander Capehart
e23643f3ab
build: bump to 3.6.0 2024-10-14 12:46:07 -06:00
Alexander Capehart
344a49532b
music: fix more tab compat 2024-10-14 12:46:07 -06:00
Alexander Capehart
29e29d3cab
actions: fix ninja-build install step name 2024-10-14 12:46:07 -06:00
Alexander Capehart
10d7f5d197
actions: add ninja requirement 2024-10-14 12:46:06 -06:00
Alexander Capehart
bed1dc43cd
playback: fix gaps on playlist change 2024-10-14 12:46:06 -06:00
Alexander Capehart
19f3e07c8e
service: bundle parent info into extras
Instead of using mediaId.

This makes it so that there is only really one mediaId to work
with, with an optional extra for playback that I desperately
hope is preserved on all instances of Android Auto.
2024-10-14 12:46:06 -06:00
Alexander Capehart
0b3a136320
all: reformat 2024-10-14 12:46:06 -06:00
Alexander Capehart
adfed98b71
music: paginate browser results
Hopefully now that I'm self-rolling this it'll actually work.
2024-10-14 12:46:06 -06:00
Alexander Capehart
1a78e973d7
playback: use implicit shuffle in detail playback 2024-10-14 12:46:06 -06:00
Alexander Capehart
437d3391e7
all: reformat 2024-10-14 12:46:06 -06:00
Alexander Capehart
c236a449c8
music: introduce icon for backport more tab 2024-10-14 12:46:06 -06:00
Alexander Capehart
f0bda0c99f
service: avoid crash on death 2024-10-14 12:46:05 -06:00
Alexander Capehart
cb43b0f074
service: decouple maxtab handling and ids
Simpler and more versatile.
2024-10-14 12:46:05 -06:00
Alexander Capehart
f4589616be
music: simplify disc number resolution
Introduce a resolveDisc extension function to share disc name
resolution between detail/browser
2024-10-14 12:46:05 -06:00
Alexander Capehart
c9664d75c0
home: dont show tab icons in phone mode 2024-10-14 12:46:05 -06:00
Alexander Capehart
f84e3428f0
home: fix broken item refresh 2024-10-14 12:46:05 -06:00
Alexander Capehart
d6a0b75618
detail: fix broken item refresh 2024-10-14 12:46:05 -06:00
Alexander Capehart
6f3fc5904a
detail: generate sort header w/discs 2024-10-14 12:46:05 -06:00
Alexander Capehart
3afbedb6bd
ui: attach to generators 2024-10-14 12:46:05 -06:00
Alexander Capehart
2bd468bce3
detail: fix incorrect disc section generation 2024-10-14 12:46:04 -06:00
Alexander Capehart
751cd94736
service: re-add attach pattern
Turns out I can't actually couple creation/attach without creating a
huge amount of variable issues.
2024-10-14 12:46:04 -06:00
Alexander Capehart
e12ce82615
all: reformat 2024-10-14 12:46:04 -06:00
Alexander Capehart
14035956e6
music: tear down menus
Only works on automotive OS, which I am not targeting right now.
2024-10-14 12:46:04 -06:00
Alexander Capehart
cbdad3fe39
all: reformat/fixes 2024-10-14 12:46:04 -06:00
Alexander Capehart
26f27d0edd
detail: split off detail list into generator 2024-09-18 14:50:53 -06:00
Weblate (bot)
94c4840672
Translations update from Hosted Weblate (#850)
* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/

* Translated using Weblate (Croatian)

Currently translated at 100.0% (48 of 48 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/hr/

* Translated using Weblate (Croatian)

Currently translated at 100.0% (300 of 300 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/

* Translated using Weblate (Czech)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/

* Translated using Weblate (Finnish)

Currently translated at 92.0% (278 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/

* Translated using Weblate (German)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/

* Translated using Weblate (Hindi)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/

* Translated using Weblate (German)

Currently translated at 100.0% (50 of 50 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/de/

* Translated using Weblate (Punjabi)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pa/

* Translated using Weblate (Finnish)

Currently translated at 92.7% (280 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/

* Translated using Weblate (Finnish)

Currently translated at 98.6% (298 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fi/

* Translated using Weblate (Finnish)

Currently translated at 100.0% (50 of 50 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/fi/

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/bg/

* Translated using Weblate (Korean)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/

* Translated using Weblate (Croatian)

Currently translated at 99.6% (301 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/

* Translated using Weblate (Welsh)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cy/

* Translated using Weblate (Croatian)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/

* Translated using Weblate (Russian)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/

* Translated using Weblate (Interlingua)

Currently translated at 68.8% (208 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ia/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/

* Translated using Weblate (Belarusian)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/

* Translated using Weblate (Lithuanian)

Currently translated at 100.0% (302 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/

* Added translation using Weblate (Estonian)

* Translated using Weblate (Estonian)

Currently translated at 46.6% (141 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 100.0% (50 of 50 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/zh_Hant/

* Translated using Weblate (Russian)

Currently translated at 100.0% (50 of 50 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/ru/

* Translated using Weblate (Estonian)

Currently translated at 55.9% (169 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

* Translated using Weblate (Estonian)

Currently translated at 66.2% (200 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

* Translated using Weblate (Estonian)

Currently translated at 77.4% (234 of 302 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/et/

---------

Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: Riku <riksu9000@gmail.com>
Co-authored-by: qwerty287 <qwerty287@posteo.de>
Co-authored-by: ShareASmile <ShareASmile@users.noreply.hosted.weblate.org>
Co-authored-by: trunars <trunars@gmail.com>
Co-authored-by: Yurical <yurical1@outlook.com>
Co-authored-by: fin-w <fin-w@users.noreply.hosted.weblate.org>
Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Co-authored-by: Software In Interlingua <softinterlingua@gmail.com>
Co-authored-by: Vaclovas Intas <vaclovas1999@gmail.com>
Co-authored-by: Priit Jõerüüt <hwlate@joeruut.com>
Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Co-authored-by: Evgeniy Khramov <65224669+thejenja@users.noreply.github.com>
2024-09-17 15:42:29 -06:00
Alexander Capehart
f4e1681044
all: reformat 2024-09-13 13:35:48 -06:00
Alexander Capehart
a3af24688a
playback: use factory pattern 2024-09-13 13:35:46 -06:00
Alexander Capehart
8418dccdc6
music: use factory pattern in service components 2024-09-13 13:35:43 -06:00
Alexander Capehart
d2aed8ee23
music: remove category 2024-09-13 13:35:41 -06:00
Alexander Capehart
fcd4ef3dc8
all: build fixes 2024-09-13 13:35:39 -06:00
Alexander Capehart
3832c4e525
home: mirror tabs to mediasession browser 2024-09-13 13:35:37 -06:00
Alexander Capehart
29d663f500
service: share home list logic between service/ui 2024-09-13 13:35:21 -06:00
Alexander Capehart
e4310cfe17
music: fix broken android auto search 2024-08-30 10:19:31 -06:00
Alexander Capehart
fd597ea16a
music: fix root menus shown 2024-08-30 10:18:50 -06:00
Alexander Capehart
2857f7d92c
all: format/syntax fixes 2024-08-29 21:07:41 -06:00
Alexander Capehart
48568d2a1d
playback: fix mediasessionholder package 2024-08-29 21:05:48 -06:00
Alexander Capehart
3af81404ac
playback: fix mediasessionholder instantiation 2024-08-29 21:05:35 -06:00
Alexander Capehart
4e4a99bbf3
music: fix crash on browser child load 2024-08-29 21:04:06 -06:00
Alexander Capehart
2bc4ed020b
playback: fix broken mediasession lifecycle 2024-08-29 21:03:26 -06:00
Alexander Capehart
6ff2d55a68
music: fix category id 2024-08-29 21:00:13 -06:00
Alexander Capehart
463b02f871
service: remove external media3 support 2024-08-29 20:59:46 -06:00
Alexander Capehart
a29f747341
music: build session menus from resources 2024-08-29 16:39:07 -06:00
Alexander Capehart
b43dbb3e89
playback: define menu options 2024-08-29 09:55:02 -06:00
Alexander Capehart
bf50867b37
all: various cleanup 2024-08-29 09:31:27 -06:00
Alexander Capehart
889713d5e0
playback: improve queue item setup
- Use same media description code
- Make queue removal more reliable
2024-08-29 09:30:24 -06:00
Alexander Capehart
130d30c70d
playback: immprove search error cases 2024-08-28 16:38:39 -06:00
Alexander Capehart
a712a773b0
playback: correctly voice search for music
Completely misunderstood how "focus" worked.
2024-08-28 16:35:30 -06:00
Alexander Capehart
b2e7c1eb50
playback: basic play from search functionality 2024-08-28 15:52:42 -06:00
Alexander Capehart
fda4548515
music: apply descriptions everywhere 2024-08-28 15:05:26 -06:00
Alexander Capehart
cce33e1414
playback: improve published playback metadata 2024-08-28 14:09:27 -06:00
Alexander Capehart
ba5bccaa37
playback: remove specific queue item in android auto 2024-08-28 13:58:46 -06:00
Alexander Capehart
3dea060a28
all: cleanup 2024-08-28 13:29:48 -06:00
Alexander Capehart
44f9617307
playback: add missing session actions 2024-08-28 13:29:24 -06:00
Alexander Capehart
916c3c46df
playback: split up mediasession interface and holder 2024-08-28 13:26:52 -06:00
Alexander Capehart
f1e1152e21
music: make compat more menu
This way we can make sure that external providers never truncate our
MediaItem count.
2024-08-28 10:13:36 -06:00
Alexander Capehart
e23ac33b85
music: reformat 2024-08-28 09:21:23 -06:00
Alexander Capehart
66c31f4318
playback: apply missing extras 2024-08-28 09:21:03 -06:00
Alexander Capehart
30b3603cf1
music: move search/notif out of service fragment
Generally cleaner this way
2024-08-28 08:42:59 -06:00
Alexander Capehart
f30c426c77
music: apply headers to all mediaitems 2024-08-27 16:52:22 -06:00
Alexander Capehart
35646d6a2d
playback: re-add headers to search 2024-08-27 16:51:31 -06:00
Alexander Capehart
f0dda6c43e
all: cleanup 2024-08-27 16:48:56 -06:00
Alexander Capehart
924e3d1801
music: re-add search browsing 2024-08-27 16:46:44 -06:00
Alexander Capehart
b1e871c6e1
music: re-add music browsing 2024-08-27 16:46:34 -06:00
Alexander Capehart
69070e7b13
playback: port basic media descriptions 2024-08-27 10:33:54 -06:00
Alexander Capehart
e43f55bc78
service: drop media3 session entirely 2024-08-26 17:53:03 -06:00
Alexander Capehart
b8e54b3707
info: remove sponsor 2024-08-26 11:42:50 -06:00
Alexander Capehart
99c11bd27f
Merge pull request #856 from OxygenCobalt/hotfixes
Version 3.5.3
2024-08-26 11:12:46 -06:00
Alexander Capehart
c1e5adbc44
media: unwind tightly bound action handling 2024-08-23 13:55:49 -06:00
Alexander Capehart
f251813200
Merge branch 'hotfixes' into dev 2024-08-23 13:46:30 -06:00
Alexander Capehart
d91343070a
build: bump to 3.5.3
Bump the version to 3.5.3 (49)
2024-08-23 13:44:00 -06:00
Alexander Capehart
a3012abe23
info: update changelogs
- Fix typo
- Add tasker integration note
2024-08-23 13:41:48 -06:00
Alexander Capehart
3cd09c3cec
media: bump to fixed build 2024-08-23 13:28:31 -06:00
Alexander Capehart
1a490eb7b4
build: bump ndk to r26d 2024-08-23 13:01:15 -06:00
Alexander Capehart
258dd9205c
build: bump media 2024-08-22 10:59:24 -06:00
Alexander Capehart
e1f75bb337
build: bump ndk to r26b 2024-08-22 10:18:45 -06:00
Alexander Capehart
2c976374f3
tasker: use translated tasker action description 2024-08-21 13:58:19 -06:00
Alexander Capehart
cc7f9ba539
tasker: fix player main thread bugs on restore 2024-08-21 13:58:01 -06:00
Alexander Capehart
27e378ae2a
tasker: give start action real name
Instead of the template.
2024-08-21 13:57:40 -06:00
Alexander Capehart
b8a652d6f2
tasker: fix activity 2024-08-21 13:57:17 -06:00
Alexander Capehart
2e9647d1dc
ui: fix duplicate string 2024-08-21 11:34:46 -06:00
Alexander Capehart
ea9c5d3c88
tasker: add start action
Add a tasker action to start AuxioService in a HIGHLY limited ammner.

Resolves #754.
2024-08-17 18:21:39 -06:00
Alexander Capehart
3fa5628a1e
playback: introduce foreground-safe restores
- Allow DeferredPlayback.RestoreState to force-start playback
- Allow DeferredPlayback.RestoreState to specify a fallback action
guaranteed to succeed
2024-08-17 18:10:55 -06:00
Alexander Capehart
aa140bebaa
all: reformat 2024-08-14 18:58:59 -06:00
Alexander Capehart
5c779f6d89
info: update changelog 2024-08-14 18:58:52 -06:00
Alexander Capehart
dad0d75d97
music: avoid foreground crash from early loading 2024-08-14 18:53:04 -06:00
Alexander Capehart
67e51ab54c
widgets: decrease bitmap reduction 2024-08-14 18:49:56 -06:00
Alexander Capehart
ba46895ad1
widget: increase bitmap reduction 2024-08-14 18:49:48 -06:00
Alexander Capehart
d10f84efa8
widgets: move size fixing into a transform 2024-08-14 18:49:37 -06:00
Alexander Capehart
7a00c3c6aa
music: parse singular spaced artist tags
On ID3 and Vorbis.
2024-08-14 18:47:51 -06:00
Weblate (bot)
cf28adc5aa
Translations update from Hosted Weblate (#820)
* Translated using Weblate (Spanish)

Currently translated at 100.0% (48 of 48 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/es/

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (48 of 48 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/bg/

* Translated using Weblate (Punjabi)

Currently translated at 100.0% (48 of 48 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/pa/

* Translated using Weblate (Hindi)

Currently translated at 100.0% (48 of 48 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/hi/

* Translated using Weblate (German)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/

* Translated using Weblate (Portuguese (Portugal))

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_PT/

* Translated using Weblate (Portuguese)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (48 of 48 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/hu/

* Translated using Weblate (Welsh)

Currently translated at 82.8% (260 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cy/

* Translated using Weblate (Welsh)

Currently translated at 89.4% (281 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cy/

* Translated using Weblate (Greek)

Currently translated at 97.9% (47 of 48 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/el/

* Translated using Weblate (French)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (48 of 48 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/fr/

* Translated using Weblate (Korean)

Currently translated at 100.0% (48 of 48 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/ko/

---------

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: trunars <trunars@gmail.com>
Co-authored-by: ShareASmile <ShareASmile@users.noreply.hosted.weblate.org>
Co-authored-by: min7-i <min7-i@users.noreply.hosted.weblate.org>
Co-authored-by: ssantos <ssantos@web.de>
Co-authored-by: zalna Rs <rszalna0@gmail.com>
Co-authored-by: fin-w <fin-w@users.noreply.hosted.weblate.org>
Co-authored-by: mpt.c <open.alat4@slmail.me>
Co-authored-by: Victor Lamoine <victor.lamoine@gmail.com>
Co-authored-by: Yurical <yurical1@outlook.com>
Co-authored-by: Alexander Capehart <alex@oxycblt.org>
2024-08-14 03:28:26 +00:00
Alexander Capehart
ba56fe1a4a
actions: update java & artifact to v4 2024-08-01 02:34:34 +00:00
Alexander Capehart
d55db7bde4
actions: use checkout v4 2024-08-01 01:49:08 +00:00
Alexander Capehart
4c20ca2a5c
info: update changelog 2024-07-29 21:40:21 -06:00
Alexander Capehart
f0bf7af7b4
Merge branch 'playback' into dev 2024-07-29 21:38:36 -06:00
Alexander Capehart
86d9d957a2
music: propose file name as playlist name 2024-07-29 21:38:13 -06:00
Alexander Capehart
9299e03e95
widget: mitigate bitmap size calculation bug 2024-07-29 19:10:19 -06:00
Alexander Capehart
e351a91a9c
playback: do not leak indexerservicefragment 2024-07-29 18:28:05 -06:00
Alexander Capehart
9bc27a49eb
music: start indexing after bind/start command 2024-07-29 18:27:42 -06:00
Alexander Capehart
9b272bbdfe
home: fix broken sheet 2024-07-22 19:11:32 -06:00
Alexander Capehart
b020285e9f
main: simplify speed dial management 2024-07-22 19:06:39 -06:00
Alexander Capehart
7d8efce28b
ui: use 360 for extremely short layout 2024-07-22 19:02:22 -06:00
Alexander Capehart
a2d4b6e50b
all: cleanup 2024-07-20 21:39:32 -06:00
Alexander Capehart
2ecb94c97e
widgets: reduce cover size limit
Some double whammy of non-1:1 album cover support and new widget forms
apparently blew the bitmap memory capacity of widgets on some devices.

Reduce the threshold further in the hope that it'll work. Really hope
this isn't that Android 12 bug where the bitmap size calculation is
duplicated across all RemoteView persisting in these devices too.
2024-07-20 19:46:07 -06:00
Alexander Capehart
fcae1ebee9
build: update deps
agp -> 8.5.0
material -> 1.13.0-alpha04
2024-07-20 18:45:45 -06:00
Alexander Capehart
609a5f18bf
playback: fix broken queue sheet scroll 2024-07-20 18:33:23 -06:00
Alexander Capehart
7a7843f7f9
playback: fix stuck corner radius on window resize 2024-07-20 18:19:35 -06:00
Alexander Capehart
5aa4b574a8
ui: add midlarge cover style 2024-07-20 17:59:49 -06:00
Alexander Capehart
a93c527f7b
detail: dont use full cover in small layout 2024-07-20 17:57:14 -06:00
Alexander Capehart
13128ab01e
detail: ellipsize toolbar title 2024-07-20 16:34:19 -06:00
Alexander Capehart
af1ec40dbe
detail: fix issues on small form factors 2024-07-20 16:29:04 -06:00
Alexander Capehart
106194fa52
playback: add split screen playback form factor 2024-07-20 16:06:56 -06:00
Alexander Capehart
95469a554c
ui: fix multitoolbar animation error
Was misusing the material animation, this should be in line w/spec
2024-07-20 15:21:51 -06:00
Alexander Capehart
dc1fe604c4
detail: fix no divider rendering w/artist albums 2024-07-20 15:01:23 -06:00
Alexander Capehart
f3b73a5196
home: extract fab system to home 2024-07-20 14:52:03 -06:00
Alexander Capehart
80dac7d9e9
detail: eliminate dead code 2024-07-20 13:52:13 -06:00
Alexander Capehart
2f21b12beb
ui: make multitoolbar transition m3 2024-07-20 13:32:06 -06:00
Alexander Capehart
d909f2d98e
detail: make playlist view use collapsing toolbar 2024-07-20 13:13:56 -06:00
Alexander Capehart
6ea7233626
detail: make genre view use collapsing toolbar 2024-07-20 13:13:21 -06:00
Alexander Capehart
0eb3ede8ec
detail: make artist view use collapsing toolbar 2024-07-20 12:54:04 -06:00
Alexander Capehart
04265d5285
home: remove logging spamming the console 2024-07-20 11:21:24 -06:00
Alexander Capehart
3286a94b1a
playback: fix various playback layout issues 2024-07-20 11:19:58 -06:00
Alexander Capehart
86e2fd7a89
detail: make album view use collapsing toolbar 2024-07-20 11:19:18 -06:00
Alexander Capehart
4a57d85037
Merge pull request #824 from OxygenCobalt/hotfixes
Version 3.5.2
2024-07-10 07:05:41 -06:00
Alexander Capehart
cf887cacb7
build: bump to 3.5.2
Bump to version 3.5.2 (48).
2024-07-10 06:54:39 -06:00
Alexander Capehart
24dbd04ca6
music: fix broken name comparator 2024-07-10 06:51:32 -06:00
Alexander Capehart
82a015c1e1
music: handle null mediastore album name
Mostly a band-aid to make null album names correspond to a folder name
(the standard MediaStore behavior).
2024-07-05 17:32:39 -06:00
Alexander Capehart
294c558b93
playback: fix brief pause when adding songs to playlists 2024-07-05 12:12:14 -06:00
Alexander Capehart
ebdf3e153b
ui: tweak tablet playback layouts
- Use dual pane layouts on portrait and landscape
- Make buttons cope with restrictive width
2024-07-05 11:51:38 -06:00
Alexander Capehart
f9e6017b5f
build: downgrade fragment
Turns out predictive back navigationn is busted for fragments.
Disabling it for my own sanity.
2024-07-04 23:28:41 -06:00
Alexander Capehart
a959933036
ui: use z transitions everywhere
Semantically correct, and now reasonable since the UI is no longer
clipped.

Will do shared element at some point once they have predictive
back support.
2024-07-04 22:26:59 -06:00
Alexander Capehart
3d177b05f1
all: cleanup 2024-07-04 15:44:15 -06:00
Alexander Capehart
b89499fb36
ui: only offset bottom sheet content via insets 2024-07-04 15:24:50 -06:00
Alexander Capehart
ec5aca0b4c
home: hide fab when bottom sheet expands 2024-07-04 15:23:05 -06:00
Alexander Capehart
a9b25e8f10
build: upgrade deps 2024-07-04 15:16:42 -06:00
Alexander Capehart
b09237c914
playback: more standard queue sheet fading 2024-07-04 15:07:39 -06:00
Alexander Capehart
0b8c3abd7f
playback: add predictive back to queue 2024-07-04 14:01:53 -06:00
Alexander Capehart
deaed1fb79
playback: add predictive back to playback sheet 2024-07-04 13:14:12 -06:00
Alexander Capehart
e035d81ee0
ui: try band-aiding bottom sheet flickering
Use an assumed peekHeight close to the real one and reduce the
jumpiness that appears in some cases.

Resolves #631.
2024-07-04 11:44:36 -06:00
Alexander Capehart
27fb1d1823
Merge branch 'master' into dev 2024-07-04 10:10:02 -06:00
Weblate (bot)
1e65808dcc
Translations update from Hosted Weblate (#768)
* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (314 of 314 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.0% (311 of 314 strings)

Co-authored-by: santiago046 <comehere665@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt_BR/
Translation: Auxio/Strings

* Translated using Weblate (German)

Currently translated at 100.0% (314 of 314 strings)

Co-authored-by: qwerty287 <qwerty287@posteo.de>
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/
Translation: Auxio/Strings

* Translated using Weblate (Russian)

Currently translated at 100.0% (46 of 46 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/ru/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/nl/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (46 of 46 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/nl/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (46 of 46 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/pt_BR/

* Translated using Weblate (Lithuanian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/

* Translated using Weblate (Lithuanian)

Currently translated at 100.0% (46 of 46 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/lt/

* Translated using Weblate (Italian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (46 of 46 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/it/

* Translated using Weblate (German)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/

* Translated using Weblate (Welsh)

Currently translated at 65.2% (205 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cy/

* Translated using Weblate (Welsh)

Currently translated at 77.7% (244 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cy/

* Translated using Weblate (Lithuanian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/

* Translated using Weblate (Lithuanian)

Currently translated at 100.0% (46 of 46 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/lt/

* Translated using Weblate (Portuguese)

Currently translated at 36.6% (115 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pt/

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/id/

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/bg/

* Translated using Weblate (Polish)

Currently translated at 97.4% (306 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pl/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (47 of 47 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/es/

* Translated using Weblate (Czech)

Currently translated at 100.0% (47 of 47 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/cs/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (47 of 47 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/zh_Hans/

* Translated using Weblate (Lithuanian)

Currently translated at 100.0% (47 of 47 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/lt/

* Translated using Weblate (Arabic (Saudi Arabia))

Currently translated at 100.0% (47 of 47 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/ar_SA/

* Translated using Weblate (Arabic (ar_IQ))

Currently translated at 100.0% (47 of 47 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/ar_IQ/

* Translated using Weblate (Arabic)

Currently translated at 91.4% (287 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ar/

* Translated using Weblate (Norwegian Bokmål)

Currently translated at 97.7% (307 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/nb_NO/

---------

Co-authored-by: santiago046 <comehere665@gmail.com>
Co-authored-by: qwerty287 <qwerty287@posteo.de>
Co-authored-by: Jillian Österreich <contact@lumaeris.com>
Co-authored-by: Jeroen <alpenblauwtje@gmail.com>
Co-authored-by: Vaclovas Intas <vaclovas1999@gmail.com>
Co-authored-by: GB <accounts@unowen.simplelogin.com>
Co-authored-by: min7-i <min7-i@users.noreply.hosted.weblate.org>
Co-authored-by: fin-w <fin-w@users.noreply.hosted.weblate.org>
Co-authored-by: Couto <jtcouto13@gmail.com>
Co-authored-by: unsigned char <danielsteventan24@gmail.com>
Co-authored-by: trunars <trunars@gmail.com>
Co-authored-by: Eryk Michalak <gnu.ewm@protonmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: moomen bashtawi <moomenbashtawi@gmail.com>
Co-authored-by: sunniva <schildkroteskoldpadda@gmail.com>
2024-07-04 16:02:33 +00:00
Alexander Capehart
6e07b3fcfd
Merge pull request #817 from OxygenCobalt/hotfixes
Version 3.5.1
2024-07-03 22:07:27 -06:00
Alexander Capehart
a6716293cd
music: reformat 2024-07-03 21:55:54 -06:00
Alexander Capehart
03af372357
build: bump version to 3.5.1
Bump to version 3.5.1 (47).
2024-07-03 21:54:19 -06:00
Alexander Capehart
baaf30ff2f
list: add name sort fallback 2024-07-03 21:45:54 -06:00
Alexander Capehart
c761544eb7
list: fix sort regressions 2024-07-03 20:41:47 -06:00
Alexander Capehart
4c92ac0f85
list: dont abuse comparators for sort
Likely causing crashes with how they are set up.
2024-06-29 19:25:59 -06:00
Alexander Capehart
c8fa389267
music: add stack trace to async load task error 2024-06-28 20:23:29 -06:00
Alexander Capehart
368c8cf00f
music: sort songs by individual date first
While still falling back to the album date for libraries that have the
same date for all songs (like mine)

Resolves #797.
2024-06-22 13:44:42 -06:00
Alexander Capehart
f116d551da
info: update changelog 2024-06-22 12:58:11 -06:00
Alexander Capehart
5f73201c9c
home: disable progress indicator on home
Likely consuming too much CPU given the frequency of updates
2024-06-22 12:55:02 -06:00
Alexander Capehart
043bc22eea
music: avoid absurd thread creation in indexing
Instead of running MetadataRetriever multiple times, creating possibly
thousands of threads, instead just have one thread that loads multiple
MediaItems at once on a rolling basis using a patched MetadataRetriever.
2024-06-22 12:47:48 -06:00
Alexander Capehart
9083d2ae72
Merge branch 'hotfixes' into dev 2024-06-22 08:44:05 -06:00
Alexander Capehart
8beb2ef4af
info: backport f-droid desc updates 2024-06-20 23:02:43 -06:00
Alexander Capehart
addb02af73
info: update readme
- Correct build number
- Re-add yrliet ot sponsors
2024-06-20 22:57:37 -06:00
Alexander Capehart
9cc5582483
Merge pull request #806 from OxygenCobalt/3.5.0
Version 3.5.0
2024-06-20 22:47:59 -06:00
Alexander Capehart
00ad0e101c
build: bump to 3.5.0
Bump the version to 3.5.0 (46).
2024-06-20 22:23:45 -06:00
Alexander Capehart
5707aa1d31
Merge branch 'dev' of github.com:OxygenCobalt/Auxio into dev 2024-06-20 22:00:24 -06:00
Alexander Capehart
e764e8b4e4
Merge branch '3.5.0' into dev 2024-06-20 22:00:00 -06:00
Alexander Capehart
16b1aeba91
ui: tweak icon background 2024-06-20 21:59:31 -06:00
Alexander Capehart
ad17c82909
Merge pull request #795 from Martysh12/794-fix-empty-menu
Disable swiping on overridden overflow menus
2024-06-08 12:13:51 -06:00
Martin K
5767094519
ui: disable swiping on overridden overflow menus 2024-06-05 21:54:52 +03:00
Alexander Capehart
5c53615c90
ui: use standard interpolation on icon 2024-05-26 14:51:00 -06:00
Alexander Capehart
6b818030eb
ui: properly interpolate splash icon 2024-05-20 14:38:13 -06:00
Alexander Capehart
4e86a2f703
home: tune speed dial anim 2024-05-20 14:22:20 -06:00
Alexander Capehart
b824ef40fb
playback: fix album/artist marquee 2024-05-20 12:13:20 -06:00
Alexander Capehart
d293cc86b0
ui: clean out self-rolled dimens
Lots of cruft has built up with my dimensions, partially collapse them
into a more consistent set of re-usable dimens (within reason) and try
to delegate to MDC as much as possible.
2024-05-20 12:08:32 -06:00
Alexander Capehart
f742aa7592
ui: use material transitions on some shapes
These look a lot better than the old ones.
2024-05-18 23:16:09 -06:00
Alexander Capehart
e809b2875e
playback: increase skip next/prev button sizes 2024-05-18 22:32:04 -06:00
Alexander Capehart
5b2985fd6b
service: remove tasker stuff 2024-05-18 22:30:34 -06:00
Alexander Capehart
5489c08583
Merge branch 'dev' of github.com:OxygenCobalt/Auxio into dev 2024-05-18 22:16:29 -06:00
Alexander Capehart
d5086fc3e6
Merge branch 'media3' into dev 2024-05-18 22:16:01 -06:00
Alexander Capehart
eedd319575
info: remove yrliet from $16/mo sponsors 2024-05-18 03:52:48 +00:00
Alexander Capehart
9087ad5e45
playback: remove custom bitmap loading
Media3 simply will not tolerate me doing this. I am basically stuck
at the mercy of the Android OS now, until I can have my own unified
source of truth with cover loading.
2024-05-17 13:38:12 -06:00
Alexander Capehart
0a3382cafd
Merge branch 'media3' into dev 2024-04-29 11:10:03 -06:00
Weblate (bot)
4d67f481a4
Translations update from Hosted Weblate (#765)
* Translated using Weblate (Interlingua)

Currently translated at 62.9% (197 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ia/

* Translated using Weblate (Interlingua)

Currently translated at 63.8% (200 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ia/

* Translated using Weblate (Interlingua)

Currently translated at 69.6% (218 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ia/

* Translated using Weblate (Romanian)

Currently translated at 82.4% (258 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ro/

* Added translation using Weblate (Welsh)

* Translated using Weblate (Welsh)

Currently translated at 97.7% (43 of 44 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/cy/

* Translated using Weblate (Welsh)

Currently translated at 8.3% (26 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cy/

* Translated using Weblate (Welsh)

Currently translated at 10.8% (34 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cy/

* Translated using Weblate (Czech)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/

* Translated using Weblate (Welsh)

Currently translated at 62.7% (197 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cy/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/

* Translated using Weblate (Korean)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/

* Translated using Weblate (Lithuanian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/

* Translated using Weblate (Croatian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/

* Translated using Weblate (Swedish)

Currently translated at 99.6% (313 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sv/

* Translated using Weblate (Russian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/

* Translated using Weblate (Belarusian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/

* Translated using Weblate (Lithuanian)

Currently translated at 100.0% (46 of 46 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/lt/

* Translated using Weblate (Slovenian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sl/

* Translated using Weblate (Slovenian)

Currently translated at 100.0% (46 of 46 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/sl/

* Translated using Weblate (Hindi)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/

* Translated using Weblate (Punjabi)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pa/

* Translated using Weblate (French)

Currently translated at 99.6% (313 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/

* Added translation using Weblate (Bulgarian)

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (46 of 46 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/bg/

* Translated using Weblate (Bulgarian)

Currently translated at 32.8% (103 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/bg/

* Translated using Weblate (Bulgarian)

Currently translated at 64.6% (203 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/bg/

* Translated using Weblate (Bulgarian)

Currently translated at 84.0% (264 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/bg/

---------

Co-authored-by: Software In Interlingua <softinterlingua@gmail.com>
Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Co-authored-by: Languages add-on <noreply-addon-languages@weblate.org>
Co-authored-by: fin-w <fin-w@tutanota.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: fin-w <fin-w@users.noreply.hosted.weblate.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: ID J <tabby4442@gmail.com>
Co-authored-by: BMT[UA] <weblate@yopmail.com>
Co-authored-by: Vaclovas Intas <vaclovas1999@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: paddis paddis <turtle@turtle.garden>
Co-authored-by: K_Lar <zan.sprogar@gmail.com>
Co-authored-by: ShareASmile <ShareASmile@users.noreply.hosted.weblate.org>
Co-authored-by: Victor Lamoine <victor.lamoine@gmail.com>
Co-authored-by: trunars <trunars@gmail.com>
2024-04-25 09:14:58 -06:00
Weblate (bot)
a71f1ab9a6
Translations update from Hosted Weblate (#741)
* Translated using Weblate (Interlingua)

Currently translated at 62.9% (197 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ia/

* Translated using Weblate (Interlingua)

Currently translated at 63.8% (200 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ia/

* Translated using Weblate (Interlingua)

Currently translated at 69.6% (218 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ia/

* Translated using Weblate (Romanian)

Currently translated at 82.4% (258 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ro/

* Added translation using Weblate (Welsh)

* Translated using Weblate (Welsh)

Currently translated at 97.7% (43 of 44 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/cy/

* Translated using Weblate (Welsh)

Currently translated at 8.3% (26 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cy/

* Translated using Weblate (Welsh)

Currently translated at 10.8% (34 of 313 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cy/

* Translated using Weblate (Czech)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/

* Translated using Weblate (Welsh)

Currently translated at 62.7% (197 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cy/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/

* Translated using Weblate (Korean)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/

* Translated using Weblate (Lithuanian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/

* Translated using Weblate (Croatian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/

* Translated using Weblate (Swedish)

Currently translated at 99.6% (313 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sv/

* Translated using Weblate (Russian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/

* Translated using Weblate (Belarusian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/

* Translated using Weblate (Lithuanian)

Currently translated at 100.0% (46 of 46 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/lt/

* Translated using Weblate (Slovenian)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/sl/

* Translated using Weblate (Slovenian)

Currently translated at 100.0% (46 of 46 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/sl/

* Translated using Weblate (Hindi)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/

* Translated using Weblate (Punjabi)

Currently translated at 100.0% (314 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pa/

* Translated using Weblate (French)

Currently translated at 99.6% (313 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/fr/

* Added translation using Weblate (Bulgarian)

* Translated using Weblate (Bulgarian)

Currently translated at 100.0% (46 of 46 strings)

Translation: Auxio/Metadata
Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/bg/

* Translated using Weblate (Bulgarian)

Currently translated at 32.8% (103 of 314 strings)

Translation: Auxio/Strings
Translate-URL: https://hosted.weblate.org/projects/auxio/strings/bg/

---------

Co-authored-by: Software In Interlingua <softinterlingua@gmail.com>
Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Co-authored-by: Languages add-on <noreply-addon-languages@weblate.org>
Co-authored-by: fin-w <fin-w@tutanota.com>
Co-authored-by: Fjuro <fjuro@alius.cz>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: fin-w <fin-w@users.noreply.hosted.weblate.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: ID J <tabby4442@gmail.com>
Co-authored-by: BMT[UA] <weblate@yopmail.com>
Co-authored-by: Vaclovas Intas <vaclovas1999@gmail.com>
Co-authored-by: Milo Ivir <mail@milotype.de>
Co-authored-by: paddis paddis <turtle@turtle.garden>
Co-authored-by: K_Lar <zan.sprogar@gmail.com>
Co-authored-by: ShareASmile <ShareASmile@users.noreply.hosted.weblate.org>
Co-authored-by: Victor Lamoine <victor.lamoine@gmail.com>
Co-authored-by: trunars <trunars@gmail.com>
2024-04-24 09:35:39 -06:00
Alexander Capehart
b4cf6a9563
Merge branch 'media3' into dev 2024-04-20 15:04:51 -06:00
Alexander Capehart
181741bb10
ui: tweak icon colors 2024-04-19 16:04:12 -06:00
Alexander Capehart
f2bc50e611
playback: change title header style 2024-04-19 13:43:40 -06:00
Alexander Capehart
823e04b073
playback: resize elements
- Center toolbar fully to look better w/o text spacing
- Move more button back to song info since it's semantically closer there
2024-04-19 13:19:54 -06:00
Alexander Capehart
9990e00a4a
playback: make bottom sheet behavior more in-spec
Don't gradually fade out until the very end, reduce the corner radii
at the very end, fix elevation, delift elevation at the very end.

More tweaks are probably needed here to make it look good.
2024-04-19 11:04:24 -06:00
Alexander Capehart
fc90d460dc
image: use shapeappearance in coverview 2024-04-19 10:26:53 -06:00
Alexander Capehart
6c640909f7
ui: clean up material3.1 changes 2024-04-18 14:04:43 -06:00
Alexander Capehart
c90b9e5827
playback: standardize tints 2024-04-17 22:30:31 -06:00
Alexander Capehart
6c919ccd8b
widget: fix corner radius on default 2024-04-17 22:26:44 -06:00
Alexander Capehart
7995d3ac98
ui: material 3.1 (first draft)
I'm mostly cowboying through patching things to look nice. I'll re-add round mode
configs and actually try to migrate to standard spacing later.
2024-04-17 22:07:50 -06:00
Alexander Capehart
b19283002f
build: update deps 2024-04-17 22:07:38 -06:00
Alexander Capehart
754762b24d
ui: new app icon
The first march towards material design 3.1.
2024-04-17 19:50:44 -06:00
564 changed files with 26019 additions and 16829 deletions

View file

@ -34,6 +34,7 @@ body:
attributes:
label: What android version do you use?
options:
- Android 15
- Android 14
- Android 13
- Android 12L

View file

@ -11,24 +11,28 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Install ninja-build
run: sudo apt-get install -y ninja-build
- name: Clone repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Clone submodules
run: git submodule update --init --recursive --remote
- name: Set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Test app with Gradle
run: ./gradlew app:testDebug
- name: Check formatting with spotless
run: ./gradlew spotlessCheck
- name: Test musikr with Gradle
run: ./gradlew musikr:testDebug
- name: Build debug APK with Gradle
run: ./gradlew app:packageDebug
- name: Upload debug APK artifact
uses: actions/upload-artifact@v3.1.1
uses: actions/upload-artifact@v4
with:
name: Auxio_Canary
path: ./app/build/outputs/apk/debug/app-debug.apk

3
.gitignore vendored
View file

@ -13,3 +13,6 @@ captures/
.externalNativeBuild
*.iml
.cxx
.kotlin
.aider*
.env

5
.gitmodules vendored
View file

@ -1,3 +1,8 @@
[submodule "media"]
path = media
url = https://github.com/OxygenCobalt/media.git
[submodule "musikr/src/main/cpp/taglib"]
path = musikr/src/main/cpp/taglib
url = https://github.com/taglib/taglib.git
tag = ee1931b

View file

@ -1,5 +1,168 @@
# Changelog
## 4.0.3
#### What's Improved
- Improved music loader pipeline efficiency
- Made cover.png support more flexible
- Albums with the same name but different album artists are now split
if fully tagged with album artists
#### What's Fixed
- Possibly fixed cache failures on large libraries
- Possibly fixed playback state saving failing on some devices
- Fixed issue where artists w/o songs would not have a cover
- Fixed music not being reloaded when music locations changed
- Fixed tasker media control not working
- Fixed tasker playback start command never finishing
#### Dev/Meta
- Removed useless storage permissions
- Internal cleanup/simplification of musikr API
- Removed unused resources
#### What's Fixed
## 4.0.2
#### What's New
- Added back in support for cover art from cover.png/cover.jpg
- Added "As is" cover art setting
- Option to include hidden files or not (off by default)
#### What's Improved
- Reduced elevation contrast in black theme
#### What's Fixed
- Fixed incorrect extension stripping on some files
- Fixed various errors in new branding
- Fixed MTE segfault from improper string handling
#### What's Changed
- Hidden files no longer loaded by default
## 4.0.1
#### What's Fixed
- Fixed music loading hanging on files without tags
- Fixed playlists being destroyed in poorly tagged libraries
## 4.0.0
#### What's New
- A total user interface refresh based on the latest Material Design specs
- New theme palettes
- Improved designs for playback and detail views
- New app branding and icon
- Refreshed round mode
- Less intrusive music loading indicators
- **Musikr**, a brand new music loading system
- Directly accesses user files rather than unreliable media database
- Uses faster and more capable native tag parsing
- Stores cover data on-device for fast and high-quality access
- New interpretation system with many quality-of-life improvements
- Android 15 support
#### What's Improved
- Initial music loading is signifigantly faster and less resource intensive
- Album grouping no longer done with artist
- MusicBrainz IDs will no longer split albums/artists in less tagged libraries
- M3U playlist file name is now proposed if one cannot be found within the file
- Duration is now parsed from certain files that previously could not be parsed
- ID3v2 tags are now parsed from WAV files
- NN/TT tracks/discs are now handled in Vorbis
- Music library will is less likely to fail to respond to updates
- Hidden audio files can now be loaded
- Sorting songs by date now uses songs date first, before the earliest album date
- Added working layouts for small split-screen form factors
- Added fast scrolling in detail views
- Added ability to make issues and make feedback e-mails in-app
#### What's Fixed
- Fixed playback sheet flickering on warm start
- No longer possible to save a sort with no direction specified
- Fixed inconsistent corner radii in widget
- Possibly fixed foreground start music loading failures
- Fixed playlist view not exiting on deletion
#### What's Changed
- Date added is now local to when the app discovers the file and will not
persist long-term
- Songs with no album are now "Unknown album" rather than folder name
- Tab layout no longer changes depending on device configuration
- Round mode is now on by default
#### Dev/Meta
- No longer using custom logging setup
- Music loading split off into separate musikr module
## 3.6.3
#### What's Fixed
- Fixed broken replaygain
- Fixed hide collaborators being broken
- Fixed crash when navigating to artists w/appearances
- Fixed headers appearing on empty detail sections
## 3.6.2
#### What's Fixed
- Fixed broken notification close action
#### Dev/Meta
- Fixed mismatched NDK versions
## 3.6.1
#### What's Fixed
- Fixed possible crash from poor service initalization
- Fixed issue where it was impossible to edit playlists
- Fixed issue where playlist would revert to older version when re-edited
#### Dev/Meta
- Fixed service memory leaks
## 3.6.0
#### What's New
- Added support for playback from google assistant
#### What's Improved
- Home and detail UIs in Android Auto now reflect app sort settings
- Album view now shows discs in android auto
#### What's Fixed
- Fixed playback briefly pausing when adding songs to playlist
- Fixed media lists in Android Auto being truncated in some cases
- Possibly fixed duplicated song items depending on album/all children
- Possibly fixed truncated tab lists in android auto
#### Dev/Meta
- Moved to raw media session apis rather than media3 session
## 3.5.3
#### What's New
- Basic Tasker integration for safely starting Auxio's service
#### What's Improved
- Added support for informal singular-spaced tags like `album artist` in
file metadata
#### What's Fixed
- Fix "Foreground not allowed" music loading crash from starting too early
- Fixed widget not loading on some devices due to the cover being too large
## 3.5.2
#### What's Fixed
- Fixed music loading failure from improper sort systems (For real this time)
## 3.5.1
#### What's Fixed
- Fixed music loading failure from improper sort systems
## 3.5.0
#### What's New

View file

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4>
<p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.4.3">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v3.4.3&color=64B5F6&style=flat">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v4.0.4">
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v4.0.4&color=64B5F6&style=flat">
</a>
<a href="https://github.com/oxygencobalt/Auxio/releases/">
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
@ -15,7 +15,12 @@
</p>
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a> | <a href="https://github.com/OxygenCobalt/Auxio#Donate">Donate</a></h4>
<p align="center">
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="170"></a>
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="250"></a>
<a href="https://accrescent.app/app/org.oxycblt.auxio">
<img alt="Get it on Accrescent" src="https://accrescent.app/badges/get-it-on.png" width="250">
</a>
</p>
<p align="center">
<a href="https://hosted.weblate.org/engage/auxio/"><img height=64 src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a>
</p>
@ -28,14 +33,12 @@ Auxio is a local music player with a fast, reliable UI/UX without the many usele
## Screenshots
<p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot6.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot7.png" width=200>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png" width=250>
</p>
@ -51,6 +54,7 @@ precise/original dates, sort tags, and more
- SD Card-aware folder management
- Reliable playlisting functionality
- Playback state persistence
- Android auto support
- Automatic gapless playback
- Full ReplayGain support (On MP3, FLAC, OGG, OPUS, and MP4 files)
- External equalizer support (ex. Wavelet)
@ -60,13 +64,13 @@ precise/original dates, sort tags, and more
- Headset autoplay
- Stylish widgets that automatically adapt to their size
- Completely private and offline
- No rounded album covers (by default)
- No rounded album covers (if you want them)
## Permissions
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music files
- Services (`FOREGROUND_SERVICE`, `WAKE_LOCK`) to keep the music playing in the background
- Notifcations (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
- Notifications (`POST_NOTIFICATION`) to indicate ongoing playback and music loading
## Donate
@ -75,7 +79,9 @@ You can support Auxio's development through [my Github Sponsors page](https://gi
<p align="center"><b>$16/month supporters:</b></p>
<p align="center">
<a href="https://github.com/yrliet"><img src="https://avatars.githubusercontent.com/u/151430565?v=4" width=100 /><p align="center"><b><a href="https://github.com/yrliet">yrliet</a></b></p></a>
<a href="https://github.com/mark-pitblado"><img src="https://avatars.githubusercontent.com/u/86988982?v=4" width=75 /></a>
<br/>
<a href="https://github.com/mark-pitblado"><b>Mark Pitblado</b></a>
</p>
<p align="center"><b>$8/month supporters:</b></p>
@ -83,12 +89,14 @@ You can support Auxio's development through [my Github Sponsors page](https://gi
<p align="center">
<a href="https://github.com/alanorth"><img src="https://avatars.githubusercontent.com/u/191754?v=4" width=50 /></a>
<a href="https://github.com/dmint789"><img src="https://avatars.githubusercontent.com/u/53250435?v=4" width=50 /></a>
<a href="https://github.com/gtsiam"><img src="https://avatars.githubusercontent.com/u/7459196?v=4" width=50 /></a>
<a href="https://github.com/adventure-tense"><img src="https://avatars.githubusercontent.com/u/123326084?v=4" width=50 /></a>
<a href="https://github.com/slushspirit"><img src="https://avatars.githubusercontent.com/u/95902378?v=4" width=50 /></a>
</p>
## Building
Auxio relies on a custom version of Media3 that enables some extra features. This adds some caveats to the build process:
Auxio relies on a patched version of Media3 that enables some extra playback features, alongside taglib for metadata
parsing. This adds some caveats to the build process:
1. `cmake` and `ninja-build` must be installed before building the project.
2. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly
download the external code.

View file

@ -2,7 +2,6 @@ plugins {
id "com.android.application"
id "kotlin-android"
id "androidx.navigation.safeargs.kotlin"
id "com.diffplug.spotless"
id "kotlin-parcelize"
id "dagger.hilt.android.plugin"
id "kotlin-kapt"
@ -11,21 +10,19 @@ plugins {
}
android {
compileSdk 34
// NDK is not used in Auxio explicitly (used in the ffmpeg extension), but we need to specify
// it here so that binary stripping will work.
// TODO: Eventually you might just want to start vendoring the FFMpeg extension so the
// NDK use is unified
ndkVersion = "25.2.9519653"
compileSdk 35
// Auxio implicitly depends on the native modules, explicitly specify it
// here so the libraries are still stripped.
ndkVersion ndk_version
namespace "org.oxycblt.auxio"
defaultConfig {
applicationId namespace
versionName "3.5.0"
versionCode 46
versionName "4.0.4"
versionCode 63
minSdk 24
targetSdk 34
minSdk min_sdk
targetSdk target_sdk
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@ -70,6 +67,7 @@ android {
buildFeatures {
viewBinding true
buildConfig true
}
}
@ -79,16 +77,16 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
def coroutines_version = '1.7.2'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlin_coroutines_version"
// --- SUPPORT ---
// General
implementation "androidx.core:core-ktx:1.12.0"
implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.activity:activity-ktx:1.8.2"
implementation "androidx.core:core-ktx:$core_version"
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.activity:activity-ktx:1.9.3"
// noinspection GradleDependency
implementation "androidx.fragment:fragment-ktx:1.6.2"
// Components
@ -97,11 +95,13 @@ dependencies {
// TODO: Report this issue and hope for a timely fix
// noinspection GradleDependency
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.constraintlayout:constraintlayout:2.2.0"
// 1.1.0 upgrades recyclerview to 1.3.0, keep it on 1.0.0
//noinspection GradleDependency
implementation "androidx.viewpager2:viewpager2:1.0.0"
// Lifecycle
def lifecycle_version = "2.7.0"
def lifecycle_version = "2.8.7"
implementation "androidx.lifecycle:lifecycle-common:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
@ -114,30 +114,38 @@ dependencies {
// Media
implementation "androidx.media:media:1.7.0"
// Android Auto
implementation "androidx.car.app:app:1.4.0"
// Preferences
implementation "androidx.preference:preference-ktx:1.2.1"
// Database
def room_version = '2.6.1'
implementation "androidx.room:room-runtime:$room_version"
ksp "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// Build
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:$desugaring_version"
// --- SECOND PARTY ---
// Musikr
implementation project(":musikr")
// --- THIRD PARTY ---
// Exoplayer (Vendored)
implementation project(":media-lib-session")
implementation project(":media-lib-exoplayer")
implementation project(":media-lib-decoder-ffmpeg")
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
// Image loading
implementation 'io.coil-kt:coil-base:2.4.0'
implementation 'io.coil-kt.coil3:coil-core:3.0.2'
// Material
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
// PR a fix.
implementation "com.google.android.material:material:1.10.0"
implementation "com.google.android.material:material:1.13.0-alpha07"
// Dependency Injection
implementation "com.google.dagger:dagger:$hilt_version"
@ -151,24 +159,9 @@ dependencies {
// Speed dial
implementation "com.leinardi.android:speed-dial:3.3.0"
// Testing
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation "junit:junit:4.13.2"
testImplementation "io.mockk:mockk:1.13.7"
testImplementation "org.robolectric:robolectric:4.11"
testImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
// Tasker integration
implementation 'com.joaomgcd:taskerpluginlibrary:0.4.10'
spotless {
kotlin {
target "src/**/*.kt"
ktfmt().dropboxStyle()
licenseHeaderFile("NOTICE")
}
}
afterEvaluate {
preDebugBuild.dependsOn spotlessApply
// Fuzzy search
implementation 'org.apache.commons:commons-text:1.9'
}

View file

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="info_app_name" translatable="false">Auxio Debug</string>
<string name="pkg_authority_cover">org.oxycblt.auxio.debug.image.CoverProvider</string>
</resources>

View file

@ -2,9 +2,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Android 13 uses READ_MEDIA_AUDIO instead of READ_EXTERNAL_STORAGE -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@ -48,6 +45,7 @@
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:launchMode="singleTask"
android:allowCrossUidActivitySwitchFromBelow="false"
android:roundIcon="@mipmap/ic_launcher"
android:windowSoftInputMode="adjustPan">
@ -92,13 +90,22 @@
android:foregroundServiceType="mediaPlayback"
android:icon="@mipmap/ic_launcher"
android:exported="true"
android:roundIcon="@mipmap/ic_launcher">
android:roundIcon="@mipmap/ic_launcher"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
<!--
Expose Auxio's cover data to the android system
-->
<provider
android:name=".image.CoverProvider"
android:authorities="@string/pkg_authority_cover"
android:exported="true"
tools:ignore="ExportedContentProvider" />
<!--
Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
See the class for more info.
@ -135,5 +142,15 @@
android:resource="@xml/widget_info" />
</receiver>
<!-- Tasker 'start service' integration -->
<activity
android:name=".tasker.ActivityConfigStartAction"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/lbl_start_playback">
<intent-filter>
<action android:name="com.twofortyfouram.locale.intent.action.EDIT_SETTING" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -1309,7 +1309,6 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
+ " should not be set externally.");
}
if (!hideable && state == STATE_HIDDEN) {
Log.w(TAG, "Cannot set state: " + state);
return;
}
final int finalState;
@ -1390,6 +1389,10 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
return shouldRemoveExpandedCorners;
}
public void killCorners() {
materialShapeDrawable.setCornerSize(0f);
}
/**
* Gets the current state of the bottom sheet.
*
@ -1629,12 +1632,13 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
return;
}
BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked();
boolean canActuallyHide = hideable && isHideableWhenDragging();
if (backEvent == null || VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) {
// If using traditional button system nav or if pre-U, just hide or collapse the bottom sheet.
setState(hideable ? STATE_HIDDEN : STATE_COLLAPSED);
setState(canActuallyHide ? STATE_HIDDEN : STATE_COLLAPSED);
return;
}
if (hideable) {
if (canActuallyHide) {
bottomContainerBackHelper.finishBackProgressNotPersistent(
backEvent,
new AnimatorListenerAdapter() {

View file

@ -29,6 +29,7 @@ import org.oxycblt.auxio.home.HomeSettings
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.CopyleftNoticeTree
import timber.log.Timber
/**
@ -45,7 +46,11 @@ class Auxio : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
@Suppress("KotlinConstantConditions")
if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" &&
BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug") {
Timber.plant(CopyleftNoticeTree())
} else if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}

View file

@ -19,91 +19,154 @@
package org.oxycblt.auxio
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaBrowserCompat.MediaItem
import androidx.annotation.StringRes
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.music.service.IndexerServiceFragment
import org.oxycblt.auxio.playback.service.MediaSessionServiceFragment
import org.oxycblt.auxio.music.service.MusicServiceFragment
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
import timber.log.Timber
@AndroidEntryPoint
class AuxioService : MediaLibraryService(), ForegroundListener {
@Inject lateinit var mediaSessionFragment: MediaSessionServiceFragment
class AuxioService :
MediaBrowserServiceCompat(), ForegroundListener, MusicServiceFragment.Invalidator {
@Inject lateinit var playbackFragmentFactory: PlaybackServiceFragment.Factory
private lateinit var playbackFragment: PlaybackServiceFragment
@Inject lateinit var indexingFragment: IndexerServiceFragment
@Inject lateinit var musicFragmentFactory: MusicServiceFragment.Factory
private lateinit var musicFragment: MusicServiceFragment
@SuppressLint("WrongConstant")
override fun onCreate() {
super.onCreate()
mediaSessionFragment.attach(this, this)
indexingFragment.attach(this)
}
override fun onBind(intent: Intent?): IBinder? {
handleIntent(intent)
return super.onBind(intent)
playbackFragment = playbackFragmentFactory.create(this, this)
musicFragment = musicFragmentFactory.create(this, this, this)
sessionToken = playbackFragment.attach()
musicFragment.attach()
Timber.d("Service Created")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// TODO: Start command occurring from a foreign service basically implies a detached
// service, we might need more handling here.
handleIntent(intent)
return super.onStartCommand(intent, flags, startId)
super.onStartCommand(intent, flags, startId)
onHandleForeground(intent)
// If we die we want to not restart, we will immediately try to foreground in and just
// fail to start again since the activity will be dead too. This is not the semantically
// "correct" flag (normally you want START_STICKY for playback) but we need this to avoid
// weird foreground errors.
return START_NOT_STICKY
}
private fun handleIntent(intent: Intent?) {
val nativeStart = intent?.getBooleanExtra(INTENT_KEY_NATIVE_START, false) ?: false
if (!nativeStart) {
// Some foreign code started us, no guarantees about foreground stability. Figure
// out what to do.
mediaSessionFragment.handleNonNativeStart()
override fun onBind(intent: Intent): IBinder? {
val binder = super.onBind(intent)
onHandleForeground(intent)
return binder
}
private fun onHandleForeground(intent: Intent?) {
musicFragment.start()
playbackFragment.start(intent)
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
mediaSessionFragment.handleTaskRemoved()
playbackFragment.handleTaskRemoved()
}
override fun onDestroy() {
super.onDestroy()
indexingFragment.release()
mediaSessionFragment.release()
musicFragment.release()
playbackFragment.release()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession =
mediaSessionFragment.mediaSession
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot {
return musicFragment.getRoot()
}
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
updateForeground(ForegroundListener.Change.MEDIA_SESSION)
override fun onLoadItem(itemId: String, result: Result<MediaItem>) {
musicFragment.getItem(itemId, result)
}
override fun onLoadChildren(parentId: String, result: Result<MutableList<MediaItem>>) {
val maximumRootChildLimit = getRootChildrenLimit()
musicFragment.getChildren(parentId, maximumRootChildLimit, result, null)
}
override fun onLoadChildren(
parentId: String,
result: Result<MutableList<MediaItem>>,
options: Bundle
) {
val maximumRootChildLimit = getRootChildrenLimit()
musicFragment.getChildren(parentId, maximumRootChildLimit, result, options.getPage())
}
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaItem>>) {
musicFragment.search(query, result, extras?.getPage())
}
private fun getRootChildrenLimit(): Int {
return browserRootHints?.getInt(
MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT, 4) ?: 4
}
private fun Bundle.getPage(): MusicServiceFragment.Page? {
val page = getInt(MediaBrowserCompat.EXTRA_PAGE, -1).takeIf { it >= 0 } ?: return null
val pageSize =
getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1).takeIf { it > 0 } ?: return null
return MusicServiceFragment.Page(page, pageSize)
}
override fun updateForeground(change: ForegroundListener.Change) {
if (mediaSessionFragment.hasNotification()) {
val mediaNotification = playbackFragment.notification
if (mediaNotification != null) {
if (change == ForegroundListener.Change.MEDIA_SESSION) {
mediaSessionFragment.createNotification {
startForeground(it.notificationId, it.notification)
}
startForeground(mediaNotification.code, mediaNotification.build())
}
// Nothing changed, but don't show anything music related since we can always
// index during playback.
isForeground = true
} else {
indexingFragment.createNotification {
musicFragment.createNotification {
if (it != null) {
startForeground(it.code, it.build())
isForeground = true
} else {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
isForeground = false
}
}
}
}
override fun invalidateMusic(mediaId: String) {
notifyChildrenChanged(mediaId)
}
companion object {
const val ACTION_START = BuildConfig.APPLICATION_ID + ".service.START"
var isForeground = false
private set
// This is only meant for Auxio to internally ensure that it's state management will work.
const val INTENT_KEY_NATIVE_START = BuildConfig.APPLICATION_ID + ".service.NATIVE_START"
const val INTENT_KEY_START_ID = BuildConfig.APPLICATION_ID + ".service.START_ID"
}
}
@ -115,3 +178,42 @@ interface ForegroundListener {
INDEXER
}
}
/**
* Wrapper around [NotificationCompat.Builder] intended for use for [NotificationCompat]s that
* signal a Service's ongoing foreground state.
*
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class ForegroundServiceNotification(context: Context, info: ChannelInfo) :
NotificationCompat.Builder(context, info.id) {
private val notificationManager = NotificationManagerCompat.from(context)
init {
// Set up the notification channel. Foreground notifications are non-substantial, and
// thus make no sense to have lights, vibration, or lead to a notification badge.
val channel =
NotificationChannelCompat.Builder(info.id, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(context.getString(info.nameRes))
.setLightsEnabled(false)
.setVibrationEnabled(false)
.setShowBadge(false)
.build()
notificationManager.createNotificationChannel(channel)
}
/**
* The code used to identify this notification.
*
* @see NotificationManagerCompat.notify
*/
abstract val code: Int
/**
* Reduced representation of a [NotificationChannelCompat].
*
* @param id The ID of the channel.
* @param nameRes A string resource ID corresponding to the human-readable name of this channel.
*/
data class ChannelInfo(val id: String, @StringRes val nameRes: Int)
}

View file

@ -49,8 +49,10 @@ object IntegerTable {
const val VIEW_TYPE_ARTIST_SONG = 0xA00A
/** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_HEADER = 0xA00B
/** DiscHeaderViewHolder */
const val VIEW_TYPE_DISC_DIVIDER = 0xA00C
/** EditHeaderViewHolder */
const val VIEW_TYPE_EDIT_HEADER = 0xA00C
const val VIEW_TYPE_EDIT_HEADER = 0xA00D
/** PlaylistSongViewHolder */
const val VIEW_TYPE_PLAYLIST_SONG = 0xA00E
/** "Music playback" notification code */
@ -59,6 +61,12 @@ object IntegerTable {
const val INDEXER_NOTIFICATION_CODE = 0xA0A1
/** MainActivity Intent request code */
const val REQUEST_CODE = 0xA0C0
/** Activity AuxioService Start ID */
const val START_ID_ACTIVITY = 0xA050
/** Tasker AuxioService Start ID */
const val START_ID_TASKER = 0xA051
/** MediaButtonReceiver AuxioService Start ID */
const val START_ID_MEDIA_BUTTON = 0xA052
/** RepeatMode.NONE */
const val REPEAT_MODE_NONE = 0xA100
/** RepeatMode.ALL */
@ -117,10 +125,10 @@ object IntegerTable {
const val ACTION_MODE_SHUFFLE = 0xA11B
/** CoverMode.Off */
const val COVER_MODE_OFF = 0xA11C
/** CoverMode.MediaStore */
const val COVER_MODE_MEDIA_STORE = 0xA11D
/** CoverMode.Balanced */
const val COVER_MODE_BALANCED = 0xA11D
/** CoverMode.Quality */
const val COVER_MODE_QUALITY = 0xA11E
const val COVER_MODE_HIGH_QUALITY = 0xA11E
/** PlaySong.FromAll */
const val PLAY_SONG_FROM_ALL = 0xA11F
/** PlaySong.FromAlbum */
@ -133,7 +141,8 @@ object IntegerTable {
const val PLAY_SONG_FROM_PLAYLIST = 0xA123
/** PlaySong.ByItself */
const val PLAY_SONG_BY_ITSELF = 0xA124
const val PLAYER_COMMAND_INC_REPEAT_MODE = 0xA125
const val PLAYER_COMMAND_TOGGLE_SHUFFLE = 0xA126
const val PLAYER_COMMAND_EXIT = 0xA127
/** CoverMode.SaveSpace */
const val COVER_MODE_SAVE_SPACE = 0xA125
/** CoverMode.AsIs */
const val COVER_MODE_AS_IS = 0xA126
}

View file

@ -33,9 +33,8 @@ import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.state.DeferredPlayback
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.isNight
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.systemBarInsetsCompat
import timber.log.Timber as L
/**
* Auxio's single [AppCompatActivity].
@ -63,7 +62,7 @@ class MainActivity : AppCompatActivity() {
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupEdgeToEdge(binding.root)
logD("Activity created")
L.d("Activity created")
}
override fun onResume() {
@ -71,15 +70,16 @@ class MainActivity : AppCompatActivity() {
startService(
Intent(this, AuxioService::class.java)
.putExtra(AuxioService.INTENT_KEY_NATIVE_START, true))
.setAction(AuxioService.ACTION_START)
.putExtra(AuxioService.INTENT_KEY_START_ID, IntegerTable.START_ID_ACTIVITY))
if (!startIntentAction(intent)) {
// No intent action to do, just restore the previously saved state.
playbackModel.playDeferred(DeferredPlayback.RestoreState)
playbackModel.playDeferred(DeferredPlayback.RestoreState(false))
}
}
override fun onNewIntent(intent: Intent?) {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
startIntentAction(intent)
}
@ -90,10 +90,10 @@ class MainActivity : AppCompatActivity() {
// Apply the color scheme. The black theme requires it's own set of themes since
// it's not possible to modify the themes at run-time.
if (isNight && uiSettings.useBlackTheme) {
logD("Applying black theme [accent ${uiSettings.accent}]")
L.d("Applying black theme [accent ${uiSettings.accent}]")
setTheme(uiSettings.accent.blackTheme)
} else {
logD("Applying normal theme [accent ${uiSettings.accent}]")
L.d("Applying normal theme [accent ${uiSettings.accent}]")
setTheme(uiSettings.accent.theme)
}
}
@ -120,7 +120,7 @@ class MainActivity : AppCompatActivity() {
private fun startIntentAction(intent: Intent?): Boolean {
if (intent == null) {
// Nothing to do.
logD("No intent to handle")
L.d("No intent to handle")
return false
}
@ -129,7 +129,7 @@ class MainActivity : AppCompatActivity() {
// This is because onStart can run multiple times, and thus we really don't
// want to return false and override the original delayed action with a
// RestoreState action.
logD("Already used this intent")
L.d("Already used this intent")
return true
}
intent.putExtra(KEY_INTENT_USED, true)
@ -139,11 +139,11 @@ class MainActivity : AppCompatActivity() {
Intent.ACTION_VIEW -> DeferredPlayback.Open(intent.data ?: return false)
Auxio.INTENT_KEY_SHORTCUT_SHUFFLE -> DeferredPlayback.ShuffleAll
else -> {
logW("Unexpected intent ${intent.action}")
L.w("Unexpected intent ${intent.action}")
return false
}
}
logD("Translated intent to $action")
L.d("Translated intent to $action")
playbackModel.playDeferred(action)
return true
}

View file

@ -22,21 +22,25 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewTreeObserver
import android.view.WindowInsets
import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback
import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
import com.google.android.material.R as MR
import com.google.android.material.bottomsheet.BackportBottomSheetBehavior
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import com.google.android.material.transition.MaterialFadeThrough
import com.leinardi.android.speeddial.SpeedDialOverlayLayout
import com.leinardi.android.speeddial.SpeedDialActionItem
import com.leinardi.android.speeddial.SpeedDialView
import dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Field
import java.lang.reflect.Method
import javax.inject.Inject
import kotlin.math.max
import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding
@ -45,13 +49,15 @@ import org.oxycblt.auxio.detail.Show
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.Outer
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.OpenPanel
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.ui.ViewBindingFragment
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
@ -59,11 +65,12 @@ import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.coordinatorLayoutBehavior
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.systemBarInsetsCompat
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* A wrapper around the home fragment that shows the playback fragment and high-level navigation.
@ -72,7 +79,10 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
*/
@AndroidEntryPoint
class MainFragment :
ViewBindingFragment<FragmentMainBinding>(), ViewTreeObserver.OnPreDrawListener {
ViewBindingFragment<FragmentMainBinding>(),
ViewTreeObserver.OnPreDrawListener,
SpeedDialView.OnActionSelectedListener {
private val musicModel: MusicViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
private val homeModel: HomeViewModel by activityViewModels()
private val listModel: ListViewModel by activityViewModels()
@ -81,9 +91,13 @@ class MainFragment :
private var detailBackCallback: DetailBackPressedCallback? = null
private var selectionBackCallback: SelectionBackPressedCallback? = null
private var speedDialBackCallback: SpeedDialBackPressedCallback? = null
private var selectionNavigationListener: DialogAwareNavigationListener? = null
private var navigationListener: DialogAwareNavigationListener? = null
private var lastInsets: WindowInsets? = null
private var elevationNormal = 0f
private var normalCornerSize = 0f
private var maxScaleXDistance = 0f
private var sheetRising: Boolean? = null
@Inject lateinit var uiSettings: UISettings
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -98,10 +112,13 @@ class MainFragment :
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
playbackSheetBehavior.uiSettings = uiSettings
playbackSheetBehavior.makeBackgroundDrawable(requireContext())
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
queueSheetBehavior?.uiSettings = uiSettings
elevationNormal = binding.context.getDimen(R.dimen.elevation_normal)
elevationNormal = binding.context.getDimen(MR.dimen.m3_sys_elevation_level1)
// Currently all back press callbacks are handled in MainFragment, as it's not guaranteed
// that instantiating these callbacks in their respective fragments would result in the
@ -114,10 +131,9 @@ class MainFragment :
DetailBackPressedCallback(detailModel).also { detailBackCallback = it }
val selectionBackCallback =
SelectionBackPressedCallback(listModel).also { selectionBackCallback = it }
val speedDialBackCallback =
SpeedDialBackPressedCallback(homeModel).also { speedDialBackCallback = it }
speedDialBackCallback = SpeedDialBackPressedCallback()
selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection)
navigationListener = DialogAwareNavigationListener(::onExploreNavigate)
// --- UI SETUP ---
val context = requireActivity()
@ -135,30 +151,50 @@ class MainFragment :
if (queueSheetBehavior != null) {
// In portrait mode, set up click listeners on the stacked sheets.
logD("Configuring stacked bottom sheets")
L.d("Configuring stacked bottom sheets")
unlikelyToBeNull(binding.queueHandleWrapper).setOnClickListener {
playbackModel.openQueue()
}
} else {
// Dual-pane mode, manually style the static queue sheet.
logD("Configuring dual-pane bottom sheet")
L.d("Configuring dual-pane bottom sheet")
binding.queueSheet.apply {
// Emulate the elevated bottom sheet style.
background =
MaterialShapeDrawable.createWithElevationOverlay(context).apply {
fillColor = context.getAttrColorCompat(MR.attr.colorSurface)
elevation = context.getDimen(R.dimen.elevation_normal)
}
// Apply bar insets for the queue's RecyclerView to use.
setOnApplyWindowInsetsListener { v, insets ->
v.updatePadding(top = insets.systemBarInsetsCompat.top)
insets
shapeAppearanceModel =
ShapeAppearanceModel.builder(
context,
MR.style.ShapeAppearance_Material3_Corner_ExtraLarge,
MR.style.ShapeAppearanceOverlay_Material3_Corner_Top)
.build()
fillColor = context.getAttrColorCompat(MR.attr.colorSurfaceContainerHigh)
}
}
}
binding.mainScrim.setOnClickListener { homeModel.setSpeedDialOpen(false) }
binding.sheetScrim.setOnClickListener { homeModel.setSpeedDialOpen(false) }
normalCornerSize = playbackSheetBehavior.sheetBackgroundDrawable.topLeftCornerResolvedSize
maxScaleXDistance =
context.getDimen(MR.dimen.m3_back_progress_bottom_container_max_scale_x_distance)
binding.playbackSheet.elevation = 0f
binding.mainScrim.setOnClickListener { binding.homeNewPlaylistFab.close() }
binding.sheetScrim.setOnClickListener { binding.homeNewPlaylistFab.close() }
binding.homeShuffleFab.setOnClickListener { playbackModel.shuffleAll() }
binding.homeNewPlaylistFab.apply {
inflate(R.menu.new_playlist_actions)
setOnActionSelectedListener(this@MainFragment)
setChangeListener(::updateSpeedDial)
}
forceHideAllFabs()
updateSpeedDial(false)
updateFabVisibility(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
// --- VIEWMODEL SETUP ---
// This has to be done here instead of the playback panel to make sure that it's prioritized
@ -168,7 +204,9 @@ class MainFragment :
collect(detailModel.toShow.flow, ::handleShow)
collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled)
collectImmediately(homeModel.showOuter.flow, ::handleShowOuter)
collectImmediately(homeModel.speedDialOpen, ::handleSpeedDialState)
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
collectImmediately(musicModel.indexingState, ::updateIndexerState)
collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled)
collectImmediately(playbackModel.song, ::updateSong)
collectImmediately(playbackModel.openPanel.flow, ::handlePanel)
@ -179,7 +217,7 @@ class MainFragment :
val binding = requireBinding()
// Once we add the destination change callback, we will receive another initialization call,
// so handle that by resetting the flag.
requireNotNull(selectionNavigationListener) { "NavigationListener was not available" }
requireNotNull(navigationListener) { "NavigationListener was not available" }
.attach(binding.exploreNavHost.findNavController())
// Listener could still reasonably fire even if we clear the binding, attach/detach
// our pre-draw listener our listener in onStart/onStop respectively.
@ -202,7 +240,7 @@ class MainFragment :
override fun onStop() {
super.onStop()
val binding = requireBinding()
requireNotNull(selectionNavigationListener) { "NavigationListener was not available" }
requireNotNull(navigationListener) { "NavigationListener was not available" }
.release(binding.exploreNavHost.findNavController())
binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this)
}
@ -213,13 +251,15 @@ class MainFragment :
sheetBackCallback = null
detailBackCallback = null
selectionBackCallback = null
selectionNavigationListener = null
navigationListener = null
binding.homeNewPlaylistFab.setChangeListener(null)
binding.homeNewPlaylistFab.setOnActionSelectedListener(null)
}
override fun onPreDraw(): Boolean {
// TODO: Due to draw caching even *this* isn't effective enough to avoid the bottom
// sheets continually getting stuck. I need something with even more frequent updates,
// or otherwise bottom sheets get stuck.
// This is where I shove literally all the UI logic that won't behave any callback
// or "normal" method I've tried. Surely running this on every frame will actually cause
// it to work properly!
// We overload CoordinatorLayout far too much to rely on any of it's typical
// listener functionality. Just update all transitions before every draw. Should
@ -231,28 +271,55 @@ class MainFragment :
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f)
if (playbackRatio > 0f && homeModel.speedDialOpen.value) {
// Stupid hack to prevent you from sliding the sheet up without closing the speed
// dial. Filtering out ACTION_MOVE events will cause back gestures to close the speed
// dial, which is super finicky behavior.
homeModel.setSpeedDialOpen(false)
// dial. Filtering out ACTION_MOVE events will cause back gestures to close the
// speed dial, which is super finicky behavior.
val rising = playbackRatio > 0f
if (rising != sheetRising) {
sheetRising = rising
updateFabVisibility(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
}
val outPlaybackRatio = 1 - playbackRatio
val halfOutRatio = min(playbackRatio * 2, 1f)
val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2
val playbackOutRatio = 1 - min(playbackRatio * 2, 1f)
val playbackInRatio = max(playbackRatio - 0.5f, 0f) * 2
val playbackMaxXScaleDelta = maxScaleXDistance / binding.playbackSheet.width
val playbackEdgeRatio = max(playbackRatio - 0.9f, 0f) / 0.1f
val playbackBackRatio =
max(1 - ((1 - binding.playbackSheet.scaleX) / playbackMaxXScaleDelta), 0f)
val playbackLastStretchRatio = min(playbackEdgeRatio * playbackBackRatio, 1f)
binding.mainSheetScrim.alpha = playbackLastStretchRatio
playbackSheetBehavior.sheetBackgroundDrawable.setCornerSize(
normalCornerSize * (1 - playbackLastStretchRatio))
binding.exploreNavHost.isInvisible = playbackLastStretchRatio == 1f
binding.playbackSheet.translationZ = (1 - playbackLastStretchRatio) * elevationNormal
if (queueSheetBehavior != null) {
// Queue sheet available, the normal transition applies, but it now much be combined
// with another transition where the playback panel disappears and the playback bar
// appears as the queue sheet expands.
val queueRatio = max(queueSheetBehavior.calculateSlideOffset(), 0f)
val halfOutQueueRatio = min(queueRatio * 2, 1f)
val halfInQueueRatio = max(queueRatio - 0.5f, 0f) * 2
val queueInRatio = max(queueRatio - 0.5f, 0f) * 2
binding.playbackBarFragment.alpha = max(1 - halfOutRatio, halfInQueueRatio)
binding.playbackPanelFragment.alpha = min(halfInPlaybackRatio, 1 - halfOutQueueRatio)
binding.queueFragment.alpha = queueRatio
val queueMaxXScaleDelta = maxScaleXDistance / binding.queueSheet.width
val queueBackRatio =
max(1 - ((1 - binding.queueSheet.scaleX) / queueMaxXScaleDelta), 0f)
val queueEdgeRatio = max(queueRatio - 0.9f, 0f) / 0.1f
val queueBarEdgeRatio = max(queueEdgeRatio - 0.5f, 0f) * 2
val queueBarBackRatio = max(queueBackRatio - 0.5f, 0f) * 2
val queueBarRatio = min(queueBarEdgeRatio * queueBarBackRatio, 1f)
val queuePanelEdgeRatio = min(queueEdgeRatio * 2, 1f)
val queuePanelBackRatio = min(queueBackRatio * 2, 1f)
val queuePanelRatio = 1 - min(queuePanelEdgeRatio * queuePanelBackRatio, 1f)
binding.playbackBarFragment.alpha = max(playbackOutRatio, queueBarRatio)
binding.playbackPanelFragment.alpha = min(playbackInRatio, queuePanelRatio)
binding.queueFragment.alpha = queueInRatio
if (playbackModel.song.value != null) {
// Playback sheet intercepts queue sheet touch events, prevent that from
@ -262,33 +329,18 @@ class MainFragment :
}
} else {
// No queue sheet, fade normally based on the playback sheet
binding.playbackBarFragment.alpha = 1 - halfOutRatio
binding.playbackPanelFragment.alpha = halfInPlaybackRatio
binding.playbackBarFragment.alpha = playbackOutRatio
binding.playbackPanelFragment.alpha = playbackInRatio
(binding.queueSheet.background as MaterialShapeDrawable).shapeAppearanceModel =
ShapeAppearanceModel.builder()
.setTopLeftCornerSize(normalCornerSize)
.setTopRightCornerSize(normalCornerSize * (1 - playbackLastStretchRatio))
.build()
}
// Fade out the content as the playback panel expands.
// TODO: Replace with shadow?
binding.exploreNavHost.apply {
alpha = outPlaybackRatio
// Prevent interactions when the content fully fades out.
isInvisible = alpha == 0f
}
// Reduce playback sheet elevation as it expands. This involves both updating the
// shadow elevation for older versions, and fading out the background drawable
// containing the elevation overlay.
binding.playbackSheet.translationZ = elevationNormal * outPlaybackRatio
playbackSheetBehavior.sheetBackgroundDrawable.alpha = (outPlaybackRatio * 255).toInt()
// Fade out the playback bar as the panel expands.
binding.playbackBarFragment.apply {
// Prevent interactions when the playback bar fully fades out.
isInvisible = alpha == 0f
// As the playback bar expands, we also want to subtly translate the bar to
// align with the top inset. This results in both a smooth transition from the bar
// to the playback panel's toolbar, but also a correctly positioned playback bar
// for when the queue sheet expands.
lastInsets?.let { translationY = it.systemBarInsetsCompat.top * halfOutRatio }
}
// Prevent interactions when the playback panel fully fades out.
@ -296,7 +348,7 @@ class MainFragment :
binding.queueSheet.apply {
// Queue sheet (not queue content) should fade out with the playback panel.
alpha = halfInPlaybackRatio
alpha = playbackInRatio
// Prevent interactions when the queue sheet fully fades out.
binding.queueSheet.isInvisible = alpha == 0f
}
@ -315,9 +367,160 @@ class MainFragment :
requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" }
.invalidateEnabled()
// Stop the FrameLayout containing the fabs from eating touch events elsewhere
binding.mainFabContainer.isVisible =
binding.homeNewPlaylistFab.mainFab.isVisible || binding.homeShuffleFab.isVisible
return true
}
override fun onActionSelected(actionItem: SpeedDialActionItem): Boolean {
when (actionItem.id) {
R.id.action_new_playlist -> {
L.d("Creating playlist")
musicModel.createPlaylist()
}
R.id.action_import_playlist -> {
L.d("Importing playlist")
musicModel.importPlaylist()
}
else -> {}
}
// Returning false to close the speed dial results in no animation, manually close instead.
// Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
requireBinding().homeNewPlaylistFab.close()
return true
}
private fun onExploreNavigate() {
listModel.dropSelection()
updateFabVisibility(
requireBinding(),
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
}
private fun updateCurrentTab(tabType: MusicType) {
val binding = requireBinding()
updateFabVisibility(
binding, homeModel.songList.value, homeModel.isFastScrolling.value, tabType)
}
private fun updateIndexerState(state: IndexingState?) {
if (state is IndexingState.Completed && state.error == null) {
L.d("Received ok response")
val binding = requireBinding()
updateFabVisibility(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
}
}
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
val binding = requireBinding()
updateFabVisibility(binding, songs, isFastScrolling, homeModel.currentTabType.value)
}
private fun updateFabVisibility(
binding: FragmentMainBinding,
songs: List<Song>,
isFastScrolling: Boolean,
tabType: MusicType
) {
// If there are no songs, it's likely that the library has not been loaded, so
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
if (shouldHideAllFabs(binding, songs, isFastScrolling)) {
L.d("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
forceHideAllFabs()
} else {
if (tabType != MusicType.PLAYLISTS) {
if (binding.homeShuffleFab.isOrWillBeShown) {
return
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
L.d("Animating transition")
binding.homeNewPlaylistFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
if (shouldHideAllFabs(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value)) {
return
}
binding.homeShuffleFab.show()
}
})
} else {
L.d("Showing immediately")
binding.homeShuffleFab.show()
}
} else {
L.d("Showing playlist button")
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
return
}
if (binding.homeShuffleFab.isOrWillBeShown) {
L.d("Animating transition")
binding.homeShuffleFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
if (shouldHideAllFabs(
binding,
homeModel.songList.value,
homeModel.isFastScrolling.value)) {
return
}
binding.homeNewPlaylistFab.show()
}
})
} else {
L.d("Showing immediately")
binding.homeNewPlaylistFab.show()
}
}
}
}
private fun shouldHideAllFabs(
binding: FragmentMainBinding,
songs: List<Song>,
isFastScrolling: Boolean
) =
binding.exploreNavHost.findNavController().currentDestination?.id != R.id.home_fragment ||
sheetRising == true ||
songs.isEmpty() ||
isFastScrolling
private fun forceHideAllFabs() {
val binding = requireBinding()
if (binding.homeShuffleFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeShuffleFab, null, false)
}
if (binding.homeNewPlaylistFab.isOpen) {
binding.homeNewPlaylistFab.close()
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeNewPlaylistFab.mainFab, null, false)
}
}
private fun updateSpeedDial(open: Boolean) {
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
.invalidateEnabled(open)
val binding = requireBinding()
binding.mainScrim.isInvisible = !open
binding.sheetScrim.isInvisible = !open
}
private fun handleShow(show: Show?) {
when (show) {
is Show.SongAlbumDetails,
@ -343,13 +546,6 @@ class MainFragment :
homeModel.showOuter.consume()
}
private fun handleSpeedDialState(open: Boolean) {
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
.invalidateEnabled(open)
requireBinding().mainScrim.isVisible = open
requireBinding().sheetScrim.isVisible = open
}
private fun updateSong(song: Song?) {
if (song != null) {
tryShowSheets()
@ -360,7 +556,7 @@ class MainFragment :
private fun handlePanel(panel: OpenPanel?) {
if (panel == null) return
logD("Trying to update panel to $panel")
L.d("Trying to update panel to $panel")
when (panel) {
OpenPanel.MAIN -> tryClosePlaybackPanel()
OpenPanel.PLAYBACK -> tryOpenPlaybackPanel()
@ -376,7 +572,7 @@ class MainFragment :
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_COLLAPSED) {
// Playback sheet is not expanded and not hidden, we can expand it.
logD("Expanding playback sheet")
L.d("Expanding playback sheet")
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_EXPANDED
return
}
@ -387,7 +583,7 @@ class MainFragment :
queueSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) {
// Queue sheet and playback sheet is expanded, close the queue sheet so the
// playback panel can shown.
logD("Collapsing queue sheet")
L.d("Collapsing queue sheet")
queueSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
}
}
@ -398,7 +594,7 @@ class MainFragment :
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_EXPANDED) {
// Playback sheet (and possibly queue) needs to be collapsed.
logD("Collapsing playback and queue sheets")
L.d("Collapsing playback and queue sheets")
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
@ -424,7 +620,7 @@ class MainFragment :
val playbackSheetBehavior =
binding.playbackSheet.coordinatorLayoutBehavior as PlaybackBottomSheetBehavior
if (playbackSheetBehavior.targetState == BackportBottomSheetBehavior.STATE_HIDDEN) {
logD("Unhiding and enabling playback sheet")
L.d("Unhiding and enabling playback sheet")
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
// Queue sheet behavior is either collapsed or expanded, no hiding needed
@ -445,7 +641,7 @@ class MainFragment :
val queueSheetBehavior =
binding.queueSheet.coordinatorLayoutBehavior as QueueBottomSheetBehavior?
logD("Hiding and disabling playback and queue sheets")
L.d("Hiding and disabling playback and queue sheets")
// Make both bottom sheets non-draggable so the user can't halt the hiding event.
queueSheetBehavior?.apply {
@ -464,19 +660,49 @@ class MainFragment :
private val playbackSheetBehavior: PlaybackBottomSheetBehavior<*>,
private val queueSheetBehavior: QueueBottomSheetBehavior<*>?
) : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
// If expanded, collapse the queue sheet first.
override fun handleOnBackStarted(backEvent: BackEventCompat) {
if (queueSheetShown()) {
unlikelyToBeNull(queueSheetBehavior).state =
BackportBottomSheetBehavior.STATE_COLLAPSED
logD("Collapsed queue sheet")
unlikelyToBeNull(queueSheetBehavior).startBackProgress(backEvent)
}
if (playbackSheetShown()) {
playbackSheetBehavior.startBackProgress(backEvent)
return
}
}
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
if (queueSheetShown()) {
unlikelyToBeNull(queueSheetBehavior).updateBackProgress(backEvent)
return
}
// If expanded, collapse the playback sheet next.
if (playbackSheetShown()) {
playbackSheetBehavior.state = BackportBottomSheetBehavior.STATE_COLLAPSED
logD("Collapsed playback sheet")
playbackSheetBehavior.updateBackProgress(backEvent)
return
}
}
override fun handleOnBackPressed() {
if (queueSheetShown()) {
unlikelyToBeNull(queueSheetBehavior).handleBackInvoked()
return
}
if (playbackSheetShown()) {
playbackSheetBehavior.handleBackInvoked()
return
}
}
override fun handleOnBackCancelled() {
if (queueSheetShown()) {
unlikelyToBeNull(queueSheetBehavior).cancelBackProgress()
return
}
if (playbackSheetShown()) {
playbackSheetBehavior.cancelBackProgress()
return
}
}
@ -499,7 +725,7 @@ class MainFragment :
OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (detailModel.dropPlaylistEdit()) {
logD("Dropped playlist edits")
L.d("Dropped playlist edits")
}
}
@ -512,7 +738,7 @@ class MainFragment :
OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (listModel.dropSelection()) {
logD("Dropped selection")
L.d("Dropped selection")
}
}
@ -521,11 +747,11 @@ class MainFragment :
}
}
private inner class SpeedDialBackPressedCallback(private val homeModel: HomeViewModel) :
OnBackPressedCallback(false) {
private inner class SpeedDialBackPressedCallback : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
if (homeModel.speedDialOpen.value) {
homeModel.setSpeedDialOpen(false)
val binding = requireBinding()
if (binding.homeNewPlaylistFab.isOpen) {
binding.homeNewPlaylistFab.close()
}
}
@ -535,7 +761,11 @@ class MainFragment :
}
private companion object {
val SPEED_DIAL_OVERLAY_ANIMATION_DURATION_FIELD: Field by
lazyReflectedField(SpeedDialOverlayLayout::class, "mAnimationDuration")
val FAB_HIDE_FROM_USER_FIELD: Method by
lazyReflectedMethod(
FloatingActionButton::class,
"hide",
FloatingActionButton.OnVisibilityChangedListener::class,
Boolean::class)
}
}

View file

@ -19,45 +19,34 @@
package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.LayoutInflater
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import com.google.android.material.transition.MaterialSharedAxis
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.header.AlbumDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.canScroll
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* A [ListFragment] that shows information about an [Album].
@ -65,60 +54,17 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class AlbumDetailFragment :
ListFragment<Song, FragmentDetailBinding>(),
AlbumDetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Song> {
private val detailModel: DetailViewModel by activityViewModels()
override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
class AlbumDetailFragment : DetailFragment<Album, Song>() {
// Information about what album to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an album.
private val args: AlbumDetailFragmentArgs by navArgs()
private val albumHeaderAdapter = AlbumDetailHeaderAdapter(this)
private val albumListAdapter = AlbumDetailListAdapter(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Detail transitions are always on the X axis. Shared element transitions are more
// semantically correct, but are also too buggy to be sensible.
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
binding.detailSelectionToolbar
override fun getDetailListAdapter() = albumListAdapter
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP --
binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() }
overrideOnOverflowMenuClick {
listModel.openMenu(
R.menu.detail_album, unlikelyToBeNull(detailModel.currentAlbum.value))
}
}
binding.detailRecycler.apply {
adapter = ConcatAdapter(albumHeaderAdapter, albumListAdapter)
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item = detailModel.albumSongList.value[it - 1]
item is Divider || item is Header || item is Disc
} else {
true
}
}
}
// -- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setAlbum(args.albumUid)
@ -136,8 +82,6 @@ class AlbumDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.albumSongInstructions.consume()
@ -147,34 +91,68 @@ class AlbumDetailFragment :
playbackModel.play(item, detailModel.playInAlbumWith)
}
override fun onOpenParentMenu() {
listModel.openMenu(R.menu.detail_album, unlikelyToBeNull(detailModel.currentAlbum.value))
}
override fun onOpenMenu(item: Song) {
listModel.openMenu(R.menu.album_song, item, detailModel.playInAlbumWith)
}
override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
}
override fun onShuffle() {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
}
override fun onOpenSortMenu() {
findNavController().navigateSafe(AlbumDetailFragmentDirections.sort())
}
override fun onNavigateToParentArtist() {
detailModel.showArtist(unlikelyToBeNull(detailModel.currentAlbum.value))
}
private fun updateAlbum(album: Album?) {
if (album == null) {
logD("No album to show, navigating away")
L.d("No album to show, navigating away")
findNavController().navigateUp()
return
}
requireBinding().detailNormalToolbar.title = album.name.resolve(requireContext())
albumHeaderAdapter.setParent(album)
val binding = requireBinding()
val context = requireContext()
val name = album.name.resolve(context)
binding.detailToolbarTitle.text = name
binding.detailCover.bind(album)
// The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = album.releaseType.resolve(context)
binding.detailName.text = name
// Artist name maps to the subhead text
binding.detailSubhead.apply {
text = album.artists.resolveNames(context)
// Add a QoL behavior where navigation to the artist will occur if the artist
// name is pressed.
setOnClickListener {
detailModel.showArtist(unlikelyToBeNull(detailModel.currentAlbum.value))
}
}
// Date, song count, and duration map to the info text
binding.detailInfo.apply {
// Fall back to a friendlier "No date" text if the album doesn't have date information
val date = album.dates?.resolve(context) ?: context.getString(R.string.def_date)
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
val duration = album.durationMs.formatDurationMs(true)
text = context.getString(R.string.fmt_three, date, songCount, duration)
}
binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
}
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
}
binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
}
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
}
updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
}
private fun updateList(list: List<Item>) {
@ -185,7 +163,7 @@ class AlbumDetailFragment :
val binding = requireBinding()
when (show) {
is Show.SongDetails -> {
logD("Navigating to ${show.song}")
L.d("Navigating to ${show.song}")
findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showSong(show.song.uid))
}
@ -194,11 +172,11 @@ class AlbumDetailFragment :
// fragment should be launched otherwise.
is Show.SongAlbumDetails -> {
if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.song.album) {
logD("Navigating to a ${show.song} in this album")
L.d("Navigating to a ${show.song} in this album")
scrollToAlbumSong(show.song)
detailModel.toShow.consume()
} else {
logD("Navigating to the album of ${show.song}")
L.d("Navigating to the album of ${show.song}")
findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.song.album.uid))
}
@ -208,27 +186,27 @@ class AlbumDetailFragment :
// detail fragment.
is Show.AlbumDetails -> {
if (unlikelyToBeNull(detailModel.currentAlbum.value) == show.album) {
logD("Navigating to the top of this album")
L.d("Navigating to the top of this album")
binding.detailRecycler.scrollToPosition(0)
detailModel.toShow.consume()
} else {
logD("Navigating to ${show.album}")
L.d("Navigating to ${show.album}")
findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showAlbum(show.album.uid))
}
}
is Show.ArtistDetails -> {
logD("Navigating to ${show.artist}")
L.d("Navigating to ${show.artist}")
findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showArtist(show.artist.uid))
}
is Show.SongArtistDecision -> {
logD("Navigating to artist choices for ${show.song}")
L.d("Navigating to artist choices for ${show.song}")
findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.song.uid))
}
is Show.AlbumArtistDecision -> {
logD("Navigating to artist choices for ${show.album}")
L.d("Navigating to artist choices for ${show.album}")
findNavController()
.navigateSafe(AlbumDetailFragmentDirections.showArtistChoices(show.album.uid))
}
@ -271,7 +249,7 @@ class AlbumDetailFragment :
val directions =
when (decision) {
is PlaylistDecision.Add -> {
logD("Adding ${decision.songs.size} songs to a playlist")
L.d("Adding ${decision.songs.size} songs to a playlist")
AlbumDetailFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray())
}
@ -300,11 +278,11 @@ class AlbumDetailFragment :
val directions =
when (decision) {
is PlaybackDecision.PlayFromArtist -> {
logD("Launching play from artist dialog for $decision")
L.d("Launching play from artist dialog for $decision")
AlbumDetailFragmentDirections.playFromArtist(decision.song.uid)
}
is PlaybackDecision.PlayFromGenre -> {
logD("Launching play from artist dialog for $decision")
L.d("Launching play from artist dialog for $decision")
AlbumDetailFragmentDirections.playFromGenre(decision.song.uid)
}
}
@ -318,6 +296,14 @@ class AlbumDetailFragment :
if (pos != -1) {
// Only scroll if the song is within this album.
val binding = requireBinding()
// RecyclerView will scroll assuming it has the total height of the screen (i.e a
// collapsed appbar), so we need to collapse the appbar if that's the case.
binding.detailAppbar.setExpanded(false)
if (!binding.detailRecycler.canScroll()) {
// Don't scroll if the RecyclerView goes off screen. If we go anyway, overscroll
// kicks in and creates a weird bounce effect.
return
}
binding.detailRecycler.post {
// Use a custom smooth scroller that will settle the item in the middle of
// the screen rather than the end.
@ -340,12 +326,9 @@ class AlbumDetailFragment :
// Make sure to increment the position to make up for the detail header
binding.detailRecycler.layoutManager?.startSmoothScroll(centerSmoothScroller)
}
}
}
// If the recyclerview can scroll, its certain that it will have to scroll to
// correctly center the playing item, so make sure that the Toolbar is lifted in
// that case.
binding.detailAppbar.isLifted = binding.detailRecycler.canScroll()
}
}
}
private fun RecyclerView.canScroll() = computeVerticalScrollRange() > height
}

View file

@ -19,44 +19,33 @@
package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.LayoutInflater
import androidx.fragment.app.activityViewModels
import androidx.core.view.isVisible
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.header.ArtistDetailHeaderAdapter
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* A [ListFragment] that shows information about an [Artist].
@ -64,63 +53,17 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class ArtistDetailFragment :
ListFragment<Music, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Music> {
private val detailModel: DetailViewModel by activityViewModels()
override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
class ArtistDetailFragment : DetailFragment<Artist, Music>() {
// Information about what artist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an artist.
private val args: ArtistDetailFragmentArgs by navArgs()
private val artistHeaderAdapter = ArtistDetailHeaderAdapter(this)
private val artistListAdapter = ArtistDetailListAdapter(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Detail transitions are always on the X axis. Shared element transitions are more
// semantically correct, but are also too buggy to be sensible.
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
binding.detailSelectionToolbar
override fun getDetailListAdapter() = artistListAdapter
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@ArtistDetailFragment)
overrideOnOverflowMenuClick {
listModel.openMenu(
R.menu.detail_parent, unlikelyToBeNull(detailModel.currentArtist.value))
}
}
binding.detailRecycler.apply {
adapter = ConcatAdapter(artistHeaderAdapter, artistListAdapter)
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item =
detailModel.artistSongList.value.getOrElse(it - 1) {
return@setFullWidthLookup false
}
item is Divider || item is Header
} else {
true
}
}
}
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setArtist(args.artistUid)
@ -138,8 +81,6 @@ class ArtistDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.artistSongInstructions.consume()
@ -153,6 +94,10 @@ class ArtistDetailFragment :
}
}
override fun onOpenParentMenu() {
listModel.openMenu(R.menu.detail_parent, unlikelyToBeNull(detailModel.currentArtist.value))
}
override fun onOpenMenu(item: Music) {
when (item) {
is Song -> listModel.openMenu(R.menu.artist_song, item, detailModel.playInArtistWith)
@ -161,26 +106,75 @@ class ArtistDetailFragment :
}
}
override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
}
override fun onShuffle() {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
}
override fun onOpenSortMenu() {
findNavController().navigateSafe(ArtistDetailFragmentDirections.sort())
}
private fun updateArtist(artist: Artist?) {
if (artist == null) {
logD("No artist to show, navigating away")
L.d("No artist to show, navigating away")
findNavController().navigateUp()
return
}
requireBinding().detailNormalToolbar.title = artist.name.resolve(requireContext())
artistHeaderAdapter.setParent(artist)
val binding = requireBinding()
val context = requireContext()
val name = artist.name.resolve(context)
binding.detailToolbarTitle.text = name
binding.detailCover.bind(artist)
binding.detailType.text = context.getString(R.string.lbl_artist)
binding.detailName.text = name
// Song and album counts map to the info
binding.detailInfo.text =
context.getString(
R.string.fmt_two,
if (artist.explicitAlbums.isNotEmpty()) {
context.getPlural(R.plurals.fmt_album_count, artist.explicitAlbums.size)
} else {
context.getString(R.string.def_album_count)
},
if (artist.songs.isNotEmpty()) {
context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
} else {
context.getString(R.string.def_song_count)
})
if (artist.songs.isNotEmpty()) {
// Information about the artist's genre(s) map to the sub-head text
binding.detailSubhead.apply {
isVisible = true
text = artist.genres.resolveNames(context)
}
// In the case that this header used to he configured to have no songs,
// we want to reset the visibility of all information that was hidden.
binding.detailPlayButton?.isVisible = true
binding.detailShuffleButton?.isVisible = true
} else {
// The artist does not have any songs, so hide functionality that makes no sense.
// ex. Play and Shuffle, Song Counts, and Genre Information.
// Artists are always guaranteed to have albums however, so continue to show those.
L.d("Artist is empty, disabling genres and playback")
binding.detailSubhead.isVisible = false
binding.detailPlayButton?.isEnabled = false
binding.detailShuffleButton?.isEnabled = false
}
binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
}
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
}
binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
}
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
}
updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
}
private fun updateList(list: List<Item>) {
@ -191,14 +185,14 @@ class ArtistDetailFragment :
val binding = requireBinding()
when (show) {
is Show.SongDetails -> {
logD("Navigating to ${show.song}")
L.d("Navigating to ${show.song}")
findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showSong(show.song.uid))
}
// Songs should be shown in their album, not in their artist.
is Show.SongAlbumDetails -> {
logD("Navigating to the album of ${show.song}")
L.d("Navigating to the album of ${show.song}")
findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.song.album.uid))
}
@ -206,7 +200,7 @@ class ArtistDetailFragment :
// Launch a new detail view for an album, even if it is part of
// this artist.
is Show.AlbumDetails -> {
logD("Navigating to ${show.album}")
L.d("Navigating to ${show.album}")
findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showAlbum(show.album.uid))
}
@ -215,22 +209,22 @@ class ArtistDetailFragment :
// scroll back to the top. Otherwise launch a new detail view.
is Show.ArtistDetails -> {
if (show.artist == detailModel.currentArtist.value) {
logD("Navigating to the top of this artist")
L.d("Navigating to the top of this artist")
binding.detailRecycler.scrollToPosition(0)
detailModel.toShow.consume()
} else {
logD("Navigating to ${show.artist}")
L.d("Navigating to ${show.artist}")
findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid))
}
}
is Show.SongArtistDecision -> {
logD("Navigating to artist choices for ${show.song}")
L.d("Navigating to artist choices for ${show.song}")
findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.song.uid))
}
is Show.AlbumArtistDecision -> {
logD("Navigating to artist choices for ${show.album}")
L.d("Navigating to artist choices for ${show.album}")
findNavController()
.navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.album.uid))
}
@ -274,7 +268,7 @@ class ArtistDetailFragment :
val directions =
when (decision) {
is PlaylistDecision.Add -> {
logD("Adding ${decision.songs.size} songs to a playlist")
L.d("Adding ${decision.songs.size} songs to a playlist")
ArtistDetailFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray())
}
@ -315,7 +309,7 @@ class ArtistDetailFragment :
is PlaybackDecision.PlayFromArtist ->
error("Unexpected playback decision $decision")
is PlaybackDecision.PlayFromGenre -> {
logD("Launching play from artist dialog for $decision")
L.d("Launching play from artist dialog for $decision")
ArtistDetailFragmentDirections.playFromGenre(decision.song.uid)
}
}

View file

@ -0,0 +1,116 @@
/*
* Copyright (c) 2022 Auxio Project
* ContinuousAppBarLayoutBehavior.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.ViewGroup
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout
class ContinuousAppBarLayoutBehavior
@JvmOverloads
constructor(context: Context? = null, attrs: AttributeSet? = null) :
AppBarLayout.Behavior(context, attrs) {
private var recycler: RecyclerView? = null
private var pointerId = -1
private var velocityTracker: VelocityTracker? = null
override fun onInterceptTouchEvent(
parent: CoordinatorLayout,
child: AppBarLayout,
ev: MotionEvent
): Boolean {
val consumed = super.onInterceptTouchEvent(parent, child, ev)
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
ensureVelocityTracker()
findRecyclerView(child).stopScroll()
pointerId = ev.getPointerId(0)
}
MotionEvent.ACTION_CANCEL -> {
velocityTracker?.recycle()
velocityTracker = null
pointerId = -1
}
else -> {}
}
return consumed
}
override fun onTouchEvent(
parent: CoordinatorLayout,
child: AppBarLayout,
ev: MotionEvent
): Boolean {
val consumed = super.onTouchEvent(parent, child, ev)
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
ensureVelocityTracker()
pointerId = ev.getPointerId(0)
}
MotionEvent.ACTION_UP -> {
findRecyclerView(child).fling(0, getYVelocity(ev))
}
MotionEvent.ACTION_CANCEL -> {
velocityTracker?.recycle()
velocityTracker = null
pointerId = -1
}
else -> {}
}
velocityTracker?.addMovement(ev)
return consumed
}
private fun ensureVelocityTracker() {
if (velocityTracker == null) {
velocityTracker = VelocityTracker.obtain()
}
}
private fun getYVelocity(event: MotionEvent): Int {
velocityTracker?.let {
it.addMovement(event)
it.computeCurrentVelocity(FLING_UNITS)
return -it.getYVelocity(pointerId).toInt()
}
return 0
}
private fun findRecyclerView(child: AppBarLayout): RecyclerView {
val recycler = recycler
if (recycler != null) {
return recycler
}
// Use the scrolling view in order to find a RecyclerView to use.
val newRecycler =
(child.parent as ViewGroup).findViewById<RecyclerView>(child.liftOnScrollTargetViewId)
this.recycler = newRecycler
return newRecycler
}
companion object {
private const val FLING_UNITS = 1000 // copied from base class
}
}

View file

@ -1,169 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* DetailAppBarLayout.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail
import android.animation.ValueAnimator
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.AttrRes
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout
import java.lang.reflect.Field
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.CoordinatorAppBarLayout
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.logD
/**
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
* view goes beyond it's first item.
*
* This is intended for the detail views, in which the first item is the album/artist/genre header,
* and thus scrolling past them should make the toolbar show the name in order to give context on
* where the user currently is.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class DetailAppBarLayout
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
CoordinatorAppBarLayout(context, attrs, defStyleAttr) {
private var titleView: TextView? = null
private var recycler: RecyclerView? = null
private var titleShown: Boolean? = null
private var titleAnimator: ValueAnimator? = null
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (!isInEditMode) {
(layoutParams as CoordinatorLayout.LayoutParams).behavior = Behavior(context)
}
}
private fun findTitleView(): TextView {
val titleView = titleView
if (titleView != null) {
return titleView
}
// Assume that we have a Toolbar with a detail_toolbar ID, as this view is only
// used within the detail layouts.
val toolbar = findViewById<Toolbar>(R.id.detail_normal_toolbar)
// The Toolbar's title view is actually hidden. To avoid having to create our own
// title view, we just reflect into Toolbar and grab the hidden field.
val newTitleView =
(TOOLBAR_TITLE_TEXT_FIELD.get(toolbar) as TextView).apply {
// We can never properly initialize the title view's state before draw time,
// so we just set it's alpha to 0f to produce a less jarring initialization
// animation.
alpha = 0f
}
this.titleView = newTitleView
return newTitleView
}
private fun findRecyclerView(): RecyclerView {
val recycler = recycler
if (recycler != null) {
return recycler
}
// Use the scrolling view in order to find a RecyclerView to use.
val newRecycler = (parent as ViewGroup).findViewById<RecyclerView>(liftOnScrollTargetViewId)
this.recycler = newRecycler
return newRecycler
}
private fun setTitleVisibility(visible: Boolean) {
if (titleShown == visible) return
titleShown = visible
// Emulate the AppBarLayout lift animation (Linear, alpha 0f -> 1f), but now with
// the title view's alpha instead of the AppBarLayout's elevation.
val titleView = findTitleView()
val from: Float
val to: Float
if (visible) {
from = 0f
to = 1f
} else {
from = 1f
to = 0f
}
if (titleView.alpha == to) {
// Nothing to do
return
}
logD("Changing title visibility [from: $from to: $to]")
titleAnimator?.cancel()
titleAnimator =
ValueAnimator.ofFloat(from, to).apply {
addUpdateListener { titleView.alpha = it.animatedValue as Float }
duration =
if (titleShown == true) {
context.getInteger(R.integer.anim_fade_enter_duration).toLong()
} else {
context.getInteger(R.integer.anim_fade_exit_duration).toLong()
}
start()
}
}
class Behavior
@JvmOverloads
constructor(context: Context? = null, attrs: AttributeSet? = null) :
AppBarLayout.Behavior(context, attrs) {
override fun onNestedPreScroll(
coordinatorLayout: CoordinatorLayout,
child: AppBarLayout,
target: View,
dx: Int,
dy: Int,
consumed: IntArray,
type: Int
) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
val appBarLayout = child as DetailAppBarLayout
val recycler = appBarLayout.findRecyclerView()
// Title should be visible if we are no longer showing the top item
// (i.e the header)
appBarLayout.setTitleVisibility(
(recycler.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() > 0)
}
}
private companion object {
val TOOLBAR_TITLE_TEXT_FIELD: Field by lazyReflectedField(Toolbar::class, "mTitleTextView")
}
}

View file

@ -0,0 +1,132 @@
/*
* Copyright (c) 2024 Auxio Project
* DetailFragment.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.LayoutInflater
import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.transition.MaterialSharedAxis
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
abstract class DetailFragment<P : MusicParent, C : Music> :
ListFragment<C, FragmentDetailBinding>(),
DetailListAdapter.Listener<C>,
AppBarLayout.OnOffsetChangedListener {
protected val detailModel: DetailViewModel by activityViewModels()
override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
private var spacingSmall = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Detail transitions are always on the X axis. Shared element transitions are more
// semantically correct, but are also too buggy to be sensible.
enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
abstract fun getDetailListAdapter(): DetailListAdapter
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
binding.detailSelectionToolbar
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailAppbar.addOnOffsetChangedListener(this)
binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@DetailFragment)
overrideOnOverflowMenuClick { onOpenParentMenu() }
}
binding.detailRecycler.apply {
adapter = getDetailListAdapter()
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item =
detailModel.artistSongList.value.getOrElse(it - 1) {
return@setFullWidthLookup false
}
item is PlainDivider || item is PlainHeader
} else {
true
}
}
}
spacingSmall = requireContext().getDimenPixels(R.dimen.spacing_small)
}
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailAppbar.removeOnOffsetChangedListener(this)
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
}
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
val binding = requireBinding()
val range = appBarLayout.totalScrollRange
val ratio = abs(verticalOffset.toFloat()) / range.toFloat()
val outRatio = min(ratio * 2, 1f)
val detailHeader = binding.detailHeader
detailHeader.scaleX = 1 - 0.2f * outRatio / (5f / 3f)
detailHeader.scaleY = 1 - 0.2f * outRatio / (5f / 3f)
detailHeader.alpha = 1 - outRatio
val inRatio = max(ratio - 0.5f, 0f) * 2
val detailContent = binding.detailToolbarContent
detailContent.alpha = inRatio
detailContent.translationY = spacingSmall * (1 - inRatio)
// Enable fast scrolling once fully collapsed
binding.detailRecycler.fastScrollingEnabled = ratio == 1f
}
abstract fun onOpenParentMenu()
}

View file

@ -0,0 +1,241 @@
/*
* Copyright (c) 2024 Auxio Project
* DetailGenerator.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail
import androidx.annotation.StringRes
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.tag.Disc
import org.oxycblt.musikr.tag.ReleaseType
import timber.log.Timber as L
interface DetailGenerator {
fun any(uid: Music.UID): Detail<out MusicParent>?
fun album(uid: Music.UID): Detail<Album>?
fun artist(uid: Music.UID): Detail<Artist>?
fun genre(uid: Music.UID): Detail<Genre>?
fun playlist(uid: Music.UID): Detail<Playlist>?
fun attach()
fun release()
interface Factory {
fun create(invalidator: Invalidator): DetailGenerator
}
interface Invalidator {
fun invalidate(type: MusicType, replace: Int?)
}
}
class DetailGeneratorFactoryImpl
@Inject
constructor(private val listSettings: ListSettings, private val musicRepository: MusicRepository) :
DetailGenerator.Factory {
override fun create(invalidator: DetailGenerator.Invalidator): DetailGenerator =
DetailGeneratorImpl(invalidator, listSettings, musicRepository)
}
private class DetailGeneratorImpl(
private val invalidator: DetailGenerator.Invalidator,
private val listSettings: ListSettings,
private val musicRepository: MusicRepository
) : DetailGenerator, MusicRepository.UpdateListener, ListSettings.Listener {
override fun attach() {
listSettings.registerListener(this)
musicRepository.addUpdateListener(this)
}
override fun onAlbumSongSortChanged() {
super.onAlbumSongSortChanged()
invalidator.invalidate(MusicType.ALBUMS, -1)
}
override fun onArtistSongSortChanged() {
super.onArtistSongSortChanged()
invalidator.invalidate(MusicType.ARTISTS, -1)
}
override fun onGenreSongSortChanged() {
super.onGenreSongSortChanged()
invalidator.invalidate(MusicType.GENRES, -1)
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (changes.deviceLibrary) {
invalidator.invalidate(MusicType.ALBUMS, null)
invalidator.invalidate(MusicType.ARTISTS, null)
invalidator.invalidate(MusicType.GENRES, null)
}
if (changes.userLibrary) {
invalidator.invalidate(MusicType.PLAYLISTS, null)
}
}
override fun release() {
listSettings.unregisterListener(this)
musicRepository.removeUpdateListener(this)
}
override fun any(uid: Music.UID): Detail<out MusicParent>? {
val music = musicRepository.find(uid) ?: return null
return when (music) {
is Album -> album(uid)
is Artist -> artist(uid)
is Genre -> genre(uid)
is Playlist -> playlist(uid)
else -> null
}
}
override fun album(uid: Music.UID): Detail<Album>? {
val album = musicRepository.library?.findAlbum(uid) ?: return null
val songs = listSettings.albumSongSort.songs(album.songs)
val discs = songs.groupBy { it.disc }
val section =
if (discs.size > 1) {
DetailSection.Discs(discs)
} else {
DetailSection.Songs(songs)
}
return Detail(album, listOf(section))
}
override fun artist(uid: Music.UID): Detail<Artist>? {
val artist = musicRepository.library?.findArtist(uid) ?: return null
val grouping =
artist.explicitAlbums.groupByTo(sortedMapOf()) {
// Remap the complicated ReleaseType data structure into detail sections
when (it.releaseType.refinement) {
ReleaseType.Refinement.LIVE -> DetailSection.Albums.Category.LIVE
ReleaseType.Refinement.REMIX -> DetailSection.Albums.Category.REMIXES
null ->
when (it.releaseType) {
is ReleaseType.Album -> DetailSection.Albums.Category.ALBUMS
is ReleaseType.EP -> DetailSection.Albums.Category.EPS
is ReleaseType.Single -> DetailSection.Albums.Category.SINGLES
is ReleaseType.Compilation -> DetailSection.Albums.Category.COMPILATIONS
is ReleaseType.Soundtrack -> DetailSection.Albums.Category.SOUNDTRACKS
is ReleaseType.Mix -> DetailSection.Albums.Category.DJ_MIXES
is ReleaseType.Mixtape -> DetailSection.Albums.Category.MIXTAPES
is ReleaseType.Demo -> DetailSection.Albums.Category.DEMOS
}
}
}
if (artist.implicitAlbums.isNotEmpty()) {
L.d("Implicit albums present, adding to list")
grouping[DetailSection.Albums.Category.APPEARANCES] =
artist.implicitAlbums.toMutableList()
}
val sections =
grouping.mapTo(mutableListOf<DetailSection>()) { (category, albums) ->
DetailSection.Albums(category, ARTIST_ALBUM_SORT.albums(albums))
}
if (artist.songs.isNotEmpty()) {
val songs = DetailSection.Songs(listSettings.artistSongSort.songs(artist.songs))
sections.add(songs)
}
return Detail(artist, sections)
}
override fun genre(uid: Music.UID): Detail<Genre>? {
val genre = musicRepository.library?.findGenre(uid) ?: return null
val artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists))
val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs))
return Detail(genre, listOf(artists, songs))
}
override fun playlist(uid: Music.UID): Detail<Playlist>? {
val playlist = musicRepository.library?.findPlaylist(uid) ?: return null
if (playlist.songs.isNotEmpty()) {
val songs = DetailSection.Songs(playlist.songs)
return Detail(playlist, listOf(songs))
}
return Detail(playlist, listOf())
}
private companion object {
val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
}
}
data class Detail<P : MusicParent>(val parent: P, val sections: List<DetailSection>)
sealed interface DetailSection {
val order: Int
val stringRes: Int
abstract class PlainSection<T : Music> : DetailSection {
abstract val items: List<T>
}
data class Artists(override val items: List<Artist>) : PlainSection<Artist>() {
override val order = 0
override val stringRes = R.string.lbl_artists
}
data class Albums(val category: Category, override val items: List<Album>) :
PlainSection<Album>() {
override val order = 1 + category.ordinal
override val stringRes = category.stringRes
enum class Category(@StringRes val stringRes: Int) {
ALBUMS(R.string.lbl_albums),
EPS(R.string.lbl_eps),
SINGLES(R.string.lbl_singles),
COMPILATIONS(R.string.lbl_compilations),
SOUNDTRACKS(R.string.lbl_soundtracks),
DJ_MIXES(R.string.lbl_mixes),
MIXTAPES(R.string.lbl_mixtapes),
DEMOS(R.string.lbl_demos),
APPEARANCES(R.string.lbl_appears_on),
LIVE(R.string.lbl_live_group),
REMIXES(R.string.lbl_remix_group)
}
}
data class Songs(override val items: List<Song>) : PlainSection<Song>() {
override val order = 12
override val stringRes = R.string.lbl_songs
}
data class Discs(val discs: Map<Disc?, List<Song>>) : DetailSection {
override val order = 13
override val stringRes = R.string.lbl_songs
}
}

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* ExternalModule.kt is part of Auxio.
* Copyright (c) 2024 Auxio Project
* DetailModule.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.external
package org.oxycblt.auxio.detail
import dagger.Binds
import dagger.Module
@ -25,11 +25,6 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface ExternalModule {
@Binds
fun externalPlaylistManager(
externalPlaylistManager: ExternalPlaylistManagerImpl
): ExternalPlaylistManager
@Binds fun m3u(m3u: M3UImpl): M3U
interface DetailModule {
@Binds fun detailGeneratorFactory(factory: DetailGeneratorFactoryImpl): DetailGenerator.Factory
}

View file

@ -18,43 +18,41 @@
package org.oxycblt.auxio.detail
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.list.DiscDivider
import org.oxycblt.auxio.detail.list.DiscHeader
import org.oxycblt.auxio.detail.list.EditHeader
import org.oxycblt.auxio.detail.list.SongProperty
import org.oxycblt.auxio.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* [ViewModel] that manages the Song, Album, Artist, and Genre detail views. Keeps track of the
@ -68,10 +66,11 @@ class DetailViewModel
constructor(
private val listSettings: ListSettings,
private val musicRepository: MusicRepository,
private val audioPropertiesFactory: AudioProperties.Factory,
private val playbackSettings: PlaybackSettings
) : ViewModel(), MusicRepository.UpdateListener {
private val playbackSettings: PlaybackSettings,
detailGeneratorFactory: DetailGenerator.Factory
) : ViewModel(), DetailGenerator.Invalidator {
private val _toShow = MutableEvent<Show>()
/**
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
*/
@ -80,30 +79,34 @@ constructor(
// --- SONG ---
private var currentSongJob: Job? = null
private val _currentSong = MutableStateFlow<Song?>(null)
/** The current [Song] to display. Null if there is nothing to show. */
val currentSong: StateFlow<Song?>
get() = _currentSong
private val _songAudioProperties = MutableStateFlow<AudioProperties?>(null)
/** The [AudioProperties] of the currently shown [Song]. Null if not loaded yet. */
val songAudioProperties: StateFlow<AudioProperties?> = _songAudioProperties
private val _currentSongProperties = MutableStateFlow<List<SongProperty>>(listOf())
/** The current properties of [currentSong]. Empty if nothing to show. */
val currentSongProperties: StateFlow<List<SongProperty>>
get() = _currentSongProperties
// --- ALBUM ---
private val _currentAlbum = MutableStateFlow<Album?>(null)
/** The current [Album] to display. Null if there is nothing to show. */
val currentAlbum: StateFlow<Album?>
get() = _currentAlbum
private val _albumSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentAlbum]. */
val albumSongList: StateFlow<List<Item>>
get() = _albumSongList
private val _albumSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [albumSongList] in the UI. */
val albumSongInstructions: Event<UpdateInstructions>
get() = _albumSongInstructions
@ -119,27 +122,25 @@ constructor(
// --- ARTIST ---
private val _currentArtist = MutableStateFlow<Artist?>(null)
/** The current [Artist] to display. Null if there is nothing to show. */
val currentArtist: StateFlow<Artist?>
get() = _currentArtist
private val _artistSongList = MutableStateFlow(listOf<Item>())
/** The current list derived from [currentArtist]. */
val artistSongList: StateFlow<List<Item>> = _artistSongList
private val _artistSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistSongList] in the UI. */
val artistSongInstructions: Event<UpdateInstructions>
get() = _artistSongInstructions
/** The current [Sort] used for [Song]s in [artistSongList]. */
var artistSongSort: Sort
val artistSongSort: Sort
get() = listSettings.artistSongSort
set(value) {
listSettings.artistSongSort = value
// Refresh the artist list to reflect the new sort.
currentArtist.value?.let { refreshArtistList(it, true) }
}
/** The [PlaySong] instructions to use when playing a [Song] from [Artist] details. */
val playInArtistWith
@ -148,27 +149,25 @@ constructor(
// --- GENRE ---
private val _currentGenre = MutableStateFlow<Genre?>(null)
/** The current [Genre] to display. Null if there is nothing to show. */
val currentGenre: StateFlow<Genre?>
get() = _currentGenre
private val _genreSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentGenre]. */
val genreSongList: StateFlow<List<Item>> = _genreSongList
private val _genreSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistSongList] in the UI. */
val genreSongInstructions: Event<UpdateInstructions>
get() = _genreSongInstructions
/** The current [Sort] used for [Song]s in [genreSongList]. */
var genreSongSort: Sort
val genreSongSort: Sort
get() = listSettings.genreSongSort
set(value) {
listSettings.genreSongSort = value
// Refresh the genre list to reflect the new sort.
currentGenre.value?.let { refreshGenreList(it, true) }
}
/** The [PlaySong] instructions to use when playing a [Song] from [Genre] details. */
val playInGenreWith
@ -177,20 +176,24 @@ constructor(
// --- PLAYLIST ---
private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
/** The current [Playlist] to display. Null if there is nothing to do. */
val currentPlaylist: StateFlow<Playlist?>
get() = _currentPlaylist
private val _playlistSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentPlaylist] */
val playlistSongList: StateFlow<List<Item>> = _playlistSongList
private val _playlistSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [playlistSongList] in the UI. */
val playlistSongInstructions: Event<UpdateInstructions>
get() = _playlistSongInstructions
private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
/**
* The new playlist songs created during the current editing session. Null if no editing session
* is occurring.
@ -204,54 +207,35 @@ constructor(
playbackSettings.inParentPlaybackMode
?: PlaySong.FromPlaylist(unlikelyToBeNull(currentPlaylist.value))
private val detailGenerator = detailGeneratorFactory.create(this)
init {
musicRepository.addUpdateListener(this)
detailGenerator.attach()
}
override fun onCleared() {
musicRepository.removeUpdateListener(this)
detailGenerator.release()
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
// If we are showing any item right now, we will need to refresh it (and any information
// related to it) with the new library in order to prevent stale items from showing up
// in the UI.
val deviceLibrary = musicRepository.deviceLibrary
if (changes.deviceLibrary && deviceLibrary != null) {
val song = currentSong.value
if (song != null) {
_currentSong.value = deviceLibrary.findSong(song.uid)?.also(::refreshAudioInfo)
logD("Updated song to ${currentSong.value}")
override fun invalidate(type: MusicType, replace: Int?) {
when (type) {
MusicType.ALBUMS -> {
val album = detailGenerator.album(currentAlbum.value?.uid ?: return)
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, replace)
}
val album = currentAlbum.value
if (album != null) {
_currentAlbum.value = deviceLibrary.findAlbum(album.uid)?.also(::refreshAlbumList)
logD("Updated album to ${currentAlbum.value}")
MusicType.ARTISTS -> {
val artist = detailGenerator.artist(currentArtist.value?.uid ?: return)
refreshDetail(
artist, _currentArtist, _artistSongList, _artistSongInstructions, replace)
}
val artist = currentArtist.value
if (artist != null) {
_currentArtist.value =
deviceLibrary.findArtist(artist.uid)?.also(::refreshArtistList)
logD("Updated artist to ${currentArtist.value}")
MusicType.GENRES -> {
val genre = detailGenerator.genre(currentGenre.value?.uid ?: return)
refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, replace)
}
val genre = currentGenre.value
if (genre != null) {
_currentGenre.value = deviceLibrary.findGenre(genre.uid)?.also(::refreshGenreList)
logD("Updated genre to ${currentGenre.value}")
}
}
val userLibrary = musicRepository.userLibrary
if (changes.userLibrary && userLibrary != null) {
val playlist = currentPlaylist.value
if (playlist != null) {
_currentPlaylist.value =
userLibrary.findPlaylist(playlist.uid)?.also(::refreshPlaylistList)
logD("Updated playlist to ${currentPlaylist.value}")
MusicType.PLAYLISTS -> {
refreshPlaylist(currentPlaylist.value?.uid ?: return)
}
else -> error("Unexpected music type $type")
}
}
@ -328,23 +312,23 @@ constructor(
private fun showImpl(show: Show) {
val existing = toShow.flow.value
if (existing != null) {
logD("Already have pending show command $existing, ignoring $show")
L.d("Already have pending show command $existing, ignoring $show")
return
}
_toShow.put(show)
}
/**
* Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will
* be updated to align with the new [Song].
* Set a new [currentSong] from it's [Music.UID]. [currentSong] will be updated to align with
* the new [Song].
*
* @param uid The UID of the [Song] to load. Must be valid.
*/
fun setSong(uid: Music.UID) {
logD("Opening song $uid")
_currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo)
L.d("Opening song $uid")
_currentSong.value = musicRepository.library?.findSong(uid)?.also(::refreshAudioInfo)
if (_currentSong.value == null) {
logW("Given song UID was invalid")
L.w("Given song UID was invalid")
}
}
@ -355,11 +339,14 @@ constructor(
* @param uid The [Music.UID] of the [Album] to update [currentAlbum] to. Must be valid.
*/
fun setAlbum(uid: Music.UID) {
logD("Opening album $uid")
_currentAlbum.value =
musicRepository.deviceLibrary?.findAlbum(uid)?.also(::refreshAlbumList)
L.d("Opening album $uid")
if (uid === _currentAlbum.value?.uid) {
return
}
val album = detailGenerator.album(uid)
refreshDetail(album, _currentAlbum, _albumSongList, _albumSongInstructions, null)
if (_currentAlbum.value == null) {
logW("Given album UID was invalid")
L.w("Given album UID was invalid")
}
}
@ -370,7 +357,6 @@ constructor(
*/
fun applyAlbumSongSort(sort: Sort) {
listSettings.albumSongSort = sort
_currentAlbum.value?.let { refreshAlbumList(it, true) }
}
/**
@ -380,12 +366,12 @@ constructor(
* @param uid The [Music.UID] of the [Artist] to update [currentArtist] to. Must be valid.
*/
fun setArtist(uid: Music.UID) {
logD("Opening artist $uid")
_currentArtist.value =
musicRepository.deviceLibrary?.findArtist(uid)?.also(::refreshArtistList)
if (_currentArtist.value == null) {
logW("Given artist UID was invalid")
L.d("Opening artist $uid")
if (uid === _currentArtist.value?.uid) {
return
}
val artist = detailGenerator.artist(uid)
refreshDetail(artist, _currentArtist, _artistSongList, _artistSongInstructions, null)
}
/**
@ -395,7 +381,6 @@ constructor(
*/
fun applyArtistSongSort(sort: Sort) {
listSettings.artistSongSort = sort
_currentArtist.value?.let { refreshArtistList(it, true) }
}
/**
@ -405,12 +390,12 @@ constructor(
* @param uid The [Music.UID] of the [Genre] to update [currentGenre] to. Must be valid.
*/
fun setGenre(uid: Music.UID) {
logD("Opening genre $uid")
_currentGenre.value =
musicRepository.deviceLibrary?.findGenre(uid)?.also(::refreshGenreList)
if (_currentGenre.value == null) {
logW("Given genre UID was invalid")
L.d("Opening genre $uid")
if (uid === _currentGenre.value?.uid) {
return
}
val genre = detailGenerator.genre(uid)
refreshDetail(genre, _currentGenre, _genreSongList, _genreSongInstructions, null)
}
/**
@ -420,7 +405,6 @@ constructor(
*/
fun applyGenreSongSort(sort: Sort) {
listSettings.genreSongSort = sort
_currentGenre.value?.let { refreshGenreList(it, true) }
}
/**
@ -430,20 +414,19 @@ constructor(
* @param uid The [Music.UID] of the [Playlist] to update [currentPlaylist] to. Must be valid.
*/
fun setPlaylist(uid: Music.UID) {
logD("Opening playlist $uid")
_currentPlaylist.value =
musicRepository.userLibrary?.findPlaylist(uid)?.also(::refreshPlaylistList)
if (_currentPlaylist.value == null) {
logW("Given playlist UID was invalid")
L.d("Opening playlist $uid")
if (uid === _currentPlaylist.value?.uid) {
return
}
refreshPlaylist(uid)
}
/** Start a playlist editing session. Does nothing if a playlist is not being shown. */
fun startPlaylistEdit() {
val playlist = _currentPlaylist.value ?: return
logD("Starting playlist edit")
L.d("Starting playlist edit")
_editedPlaylist.value = playlist.songs
refreshPlaylistList(playlist)
refreshPlaylist(playlist.uid)
}
/**
@ -453,12 +436,13 @@ constructor(
fun savePlaylistEdit() {
val playlist = _currentPlaylist.value ?: return
val editedPlaylist = _editedPlaylist.value ?: return
logD("Committing playlist edits")
L.d("Committing playlist edits")
viewModelScope.launch {
musicRepository.rewritePlaylist(playlist, editedPlaylist)
// TODO: The user could probably press some kind of button if they were fast enough.
// Think of a better way to handle this state.
_editedPlaylist.value = null
refreshPlaylist(playlist.uid)
}
}
@ -474,9 +458,8 @@ constructor(
// Nothing to do.
return false
}
logD("Discarding playlist edits")
_editedPlaylist.value = null
refreshPlaylistList(playlist)
refreshPlaylist(playlist.uid)
return true
}
@ -488,7 +471,7 @@ constructor(
fun applyPlaylistSongSort(sort: Sort) {
val playlist = _currentPlaylist.value ?: return
_editedPlaylist.value = sort.songs(_editedPlaylist.value ?: return)
refreshPlaylistList(playlist, UpdateInstructions.Replace(2))
refreshPlaylist(playlist.uid, UpdateInstructions.Replace(2))
}
/**
@ -501,15 +484,15 @@ constructor(
fun movePlaylistSongs(from: Int, to: Int): Boolean {
val playlist = _currentPlaylist.value ?: return false
val editedPlaylist = (_editedPlaylist.value ?: return false).toMutableList()
val realFrom = from - 2
val realTo = to - 2
val realFrom = from - 1
val realTo = to - 1
if (realFrom !in editedPlaylist.indices || realTo !in editedPlaylist.indices) {
return false
}
logD("Moving playlist song from $realFrom [$from] to $realTo [$to]")
L.d("Moving playlist song from $realFrom [$from] to $realTo [$to]")
editedPlaylist.add(realFrom, editedPlaylist.removeAt(realTo))
_editedPlaylist.value = editedPlaylist
refreshPlaylistList(playlist, UpdateInstructions.Move(from, to))
refreshPlaylist(playlist.uid, UpdateInstructions.Move(from, to))
return true
}
@ -521,205 +504,134 @@ constructor(
fun removePlaylistSong(at: Int) {
val playlist = _currentPlaylist.value ?: return
val editedPlaylist = (_editedPlaylist.value ?: return).toMutableList()
val realAt = at - 2
val realAt = at - 1
if (realAt !in editedPlaylist.indices) {
return
}
logD("Removing playlist song at $realAt [$at]")
L.d("Removing playlist song at $realAt [$at]")
editedPlaylist.removeAt(realAt)
_editedPlaylist.value = editedPlaylist
refreshPlaylistList(
playlist,
refreshPlaylist(
playlist.uid,
if (editedPlaylist.isNotEmpty()) {
UpdateInstructions.Remove(at, 1)
} else {
logD("Playlist will be empty after removal, removing header")
UpdateInstructions.Remove(at - 2, 3)
L.d("Playlist will be empty after removal, removing header")
UpdateInstructions.Remove(at - 1, 3)
})
}
private fun refreshAudioInfo(song: Song) {
logD("Refreshing audio info")
// Clear any previous job in order to avoid stale data from appearing in the UI.
currentSongJob?.cancel()
_songAudioProperties.value = null
currentSongJob =
viewModelScope.launch(Dispatchers.IO) {
val info = audioPropertiesFactory.extract(song)
yield()
logD("Updating audio info to $info")
_songAudioProperties.value = info
_currentSongProperties.value = buildList {
add(SongProperty(R.string.lbl_name, SongProperty.Value.MusicName(song)))
add(SongProperty(R.string.lbl_album, SongProperty.Value.MusicName(song.album)))
add(SongProperty(R.string.lbl_artists, SongProperty.Value.MusicNames(song.artists)))
add(SongProperty(R.string.lbl_genres, SongProperty.Value.MusicNames(song.genres)))
song.date?.let { add(SongProperty(R.string.lbl_date, SongProperty.Value.ItemDate(it))) }
song.track?.let {
add(SongProperty(R.string.lbl_track, SongProperty.Value.Number(it, null)))
}
song.disc?.let {
add(SongProperty(R.string.lbl_disc, SongProperty.Value.Number(it.number, it.name)))
}
add(SongProperty(R.string.lbl_path, SongProperty.Value.ItemPath(song.path)))
add(SongProperty(R.string.lbl_size, SongProperty.Value.Size(song.size)))
add(SongProperty(R.string.lbl_duration, SongProperty.Value.Duration(song.durationMs)))
add(SongProperty(R.string.lbl_format, SongProperty.Value.ItemFormat(song.format)))
add(SongProperty(R.string.lbl_bitrate, SongProperty.Value.Bitrate(song.bitrateKbps)))
add(
SongProperty(
R.string.lbl_sample_rate, SongProperty.Value.SampleRate(song.sampleRateHz)))
song.replayGainAdjustment.track?.let {
add(SongProperty(R.string.lbl_replaygain_track, SongProperty.Value.Decibels(it)))
}
song.replayGainAdjustment.album?.let {
add(SongProperty(R.string.lbl_replaygain_album, SongProperty.Value.Decibels(it)))
}
}
}
private fun refreshAlbumList(album: Album, replace: Boolean = false) {
logD("Refreshing album list")
val list = mutableListOf<Item>()
val header = SortHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
val instructions =
if (replace) {
private inline fun <T : MusicParent> refreshDetail(
detail: Detail<T>?,
parent: MutableStateFlow<T?>,
list: MutableStateFlow<List<Item>>,
instructions: MutableEvent<UpdateInstructions>,
replace: Int?,
songHeader: (Int) -> PlainHeader = { SortHeader(it) }
) {
if (detail == null) {
parent.value = null
return
}
val newList = mutableListOf<Item>()
var newInstructions: UpdateInstructions = UpdateInstructions.Diff
for ((i, section) in detail.sections.withIndex()) {
val items =
when (section) {
is DetailSection.PlainSection<*> -> {
val header =
if (section is DetailSection.Songs) songHeader(section.stringRes)
else BasicHeader(section.stringRes)
if (newList.isNotEmpty()) {
newList.add(PlainDivider(header))
}
newList.add(header)
section.items
}
is DetailSection.Discs -> {
val header = SortHeader(section.stringRes)
if (newList.isNotEmpty()) {
newList.add(PlainDivider(header))
}
newList.add(header)
buildList<Item> {
for (entry in section.discs) {
val discHeader = DiscHeader(inner = entry.key)
if (isNotEmpty()) {
add(DiscDivider(discHeader))
}
add(discHeader)
addAll(entry.value)
}
}
}
}
// Currently only the final section (songs, which can be sorted) are invalidatable
// and thus need to be replaced.
if (replace == -1 && i == detail.sections.lastIndex) {
// Intentional so that the header item isn't replaced with the songs
UpdateInstructions.Replace(list.size)
} else {
UpdateInstructions.Diff
newInstructions = UpdateInstructions.Replace(newList.size)
}
newList.addAll(items)
}
parent.value = detail.parent
instructions.put(newInstructions)
list.value = newList
}
// To create a good user experience regarding disc numbers, we group the album's
// songs up by disc and then delimit the groups by a disc header.
val songs = albumSongSort.songs(album.songs)
val byDisc = songs.groupBy { it.disc }
if (byDisc.size > 1) {
logD("Album has more than one disc, interspersing headers")
for (entry in byDisc.entries) {
list.add(DiscHeader(entry.key))
list.addAll(entry.value)
}
} else {
// Album only has one disc, don't add any redundant headers
list.addAll(songs)
}
logD("Update album list to ${list.size} items with $instructions")
_albumSongInstructions.put(instructions)
_albumSongList.value = list
}
private fun refreshArtistList(artist: Artist, replace: Boolean = false) {
logD("Refreshing artist list")
val list = mutableListOf<Item>()
val grouping =
artist.explicitAlbums.groupByTo(sortedMapOf()) {
// Remap the complicated ReleaseType data structure into an easier
// "AlbumGrouping" enum that will automatically group and sort
// the artist's albums.
when (it.releaseType.refinement) {
ReleaseType.Refinement.LIVE -> AlbumGrouping.LIVE
ReleaseType.Refinement.REMIX -> AlbumGrouping.REMIXES
null ->
when (it.releaseType) {
is ReleaseType.Album -> AlbumGrouping.ALBUMS
is ReleaseType.EP -> AlbumGrouping.EPS
is ReleaseType.Single -> AlbumGrouping.SINGLES
is ReleaseType.Compilation -> AlbumGrouping.COMPILATIONS
is ReleaseType.Soundtrack -> AlbumGrouping.SOUNDTRACKS
is ReleaseType.Mix -> AlbumGrouping.DJMIXES
is ReleaseType.Mixtape -> AlbumGrouping.MIXTAPES
is ReleaseType.Demo -> AlbumGrouping.DEMOS
}
}
}
if (artist.implicitAlbums.isNotEmpty()) {
// groupByTo normally returns a mapping to a MutableList mapping. Since MutableList
// inherits list, we can cast upwards and save a copy by directly inserting the
// implicit album list into the mapping.
logD("Implicit albums present, adding to list")
@Suppress("UNCHECKED_CAST")
(grouping as MutableMap<AlbumGrouping, Collection<Album>>)[AlbumGrouping.APPEARANCES] =
artist.implicitAlbums
}
logD("Release groups for this artist: ${grouping.keys}")
for (entry in grouping.entries) {
val header = BasicHeader(entry.key.headerTitleRes)
list.add(Divider(header))
list.add(header)
list.addAll(ARTIST_ALBUM_SORT.albums(entry.value))
}
// Artists may not be linked to any songs, only include a header entry if we have any.
var instructions: UpdateInstructions = UpdateInstructions.Diff
if (artist.songs.isNotEmpty()) {
logD("Songs present in this artist, adding header")
val header = SortHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
if (replace) {
// Intentional so that the header item isn't replaced with the songs
instructions = UpdateInstructions.Replace(list.size)
}
list.addAll(artistSongSort.songs(artist.songs))
}
logD("Updating artist list to ${list.size} items with $instructions")
_artistSongInstructions.put(instructions)
_artistSongList.value = list.toList()
}
private fun refreshGenreList(genre: Genre, replace: Boolean = false) {
logD("Refreshing genre list")
val list = mutableListOf<Item>()
// Genre is guaranteed to always have artists and songs.
val artistHeader = BasicHeader(R.string.lbl_artists)
list.add(Divider(artistHeader))
list.add(artistHeader)
list.addAll(GENRE_ARTIST_SORT.artists(genre.artists))
val songHeader = SortHeader(R.string.lbl_songs)
list.add(Divider(songHeader))
list.add(songHeader)
val instructions =
if (replace) {
// Intentional so that the header item isn't replaced alongside the songs
UpdateInstructions.Replace(list.size)
} else {
UpdateInstructions.Diff
}
list.addAll(genreSongSort.songs(genre.songs))
logD("Updating genre list to ${list.size} items with $instructions")
_genreSongInstructions.put(instructions)
_genreSongList.value = list
}
private fun refreshPlaylistList(
playlist: Playlist,
private fun refreshPlaylist(
uid: Music.UID,
instructions: UpdateInstructions = UpdateInstructions.Diff
) {
logD("Refreshing playlist list")
val list = mutableListOf<Item>()
val songs = editedPlaylist.value ?: playlist.songs
if (songs.isNotEmpty()) {
val header = EditHeader(R.string.lbl_songs)
list.add(Divider(header))
list.add(header)
list.addAll(songs)
L.d("Refreshing playlist list")
val edited = editedPlaylist.value
if (edited == null) {
val playlist = detailGenerator.playlist(uid)
refreshDetail(
playlist, _currentPlaylist, _playlistSongList, _playlistSongInstructions, null) {
EditHeader(it)
}
return
}
val list = mutableListOf<Item>()
if (edited.isNotEmpty()) {
val header = EditHeader(R.string.lbl_songs)
list.add(header)
list.addAll(edited)
}
logD("Updating playlist list to ${list.size} items with $instructions")
_playlistSongInstructions.put(instructions)
_playlistSongList.value = list
}
/**
* A simpler mapping of [ReleaseType] used for grouping and sorting songs.
*
* @param headerTitleRes The title string resource to use for a header created out of an
* instance of this enum.
*/
private enum class AlbumGrouping(@StringRes val headerTitleRes: Int) {
ALBUMS(R.string.lbl_albums),
EPS(R.string.lbl_eps),
SINGLES(R.string.lbl_singles),
COMPILATIONS(R.string.lbl_compilations),
SOUNDTRACKS(R.string.lbl_soundtracks),
DJMIXES(R.string.lbl_mixes),
MIXTAPES(R.string.lbl_mixtapes),
DEMOS(R.string.lbl_demos),
APPEARANCES(R.string.lbl_appears_on),
LIVE(R.string.lbl_live_group),
REMIXES(R.string.lbl_remix_group),
}
private companion object {
val ARTIST_ALBUM_SORT = Sort(Sort.Mode.ByDate, Sort.Direction.DESCENDING)
val GENRE_ARTIST_SORT = Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
}
}
/**

View file

@ -19,44 +19,32 @@
package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.LayoutInflater
import androidx.fragment.app.activityViewModels
import androidx.core.view.isVisible
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.header.GenreDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.DetailListAdapter
import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* A [ListFragment] that shows information for a particular [Genre].
@ -64,65 +52,21 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class GenreDetailFragment :
ListFragment<Music, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
DetailListAdapter.Listener<Music> {
private val detailModel: DetailViewModel by activityViewModels()
override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
class GenreDetailFragment : DetailFragment<Genre, Music>() {
// Information about what genre to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an genre.
private val args: GenreDetailFragmentArgs by navArgs()
private val genreHeaderAdapter = GenreDetailHeaderAdapter(this)
private val genreListAdapter = GenreDetailListAdapter(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
binding.detailSelectionToolbar
override fun getDetailListAdapter() = genreListAdapter
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
// --- UI SETUP ---
binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@GenreDetailFragment)
overrideOnOverflowMenuClick {
listModel.openMenu(
R.menu.detail_parent, unlikelyToBeNull(detailModel.currentGenre.value))
}
}
binding.detailRecycler.apply {
adapter = ConcatAdapter(genreHeaderAdapter, genreListAdapter)
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item =
detailModel.genreSongList.value.getOrElse(it - 1) {
return@setFullWidthLookup false
}
item is Divider || item is Header
} else {
true
}
}
}
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setGenre(args.genreUid)
collectImmediately(detailModel.currentGenre, ::updatePlaylist)
collectImmediately(detailModel.currentGenre, ::updateGenre)
collectImmediately(detailModel.genreSongList, ::updateList)
collect(detailModel.toShow.flow, ::handleShow)
collect(listModel.menu.flow, ::handleMenu)
@ -136,8 +80,6 @@ class GenreDetailFragment :
override fun onDestroyBinding(binding: FragmentDetailBinding) {
super.onDestroyBinding(binding)
binding.detailNormalToolbar.setOnMenuItemClickListener(null)
binding.detailRecycler.adapter = null
// Avoid possible race conditions that could cause a bad replace instruction to be consumed
// during list initialization and crash the app. Could happen if the user is fast enough.
detailModel.genreSongInstructions.consume()
@ -151,6 +93,10 @@ class GenreDetailFragment :
}
}
override fun onOpenParentMenu() {
listModel.openMenu(R.menu.detail_parent, unlikelyToBeNull(detailModel.currentGenre.value))
}
override fun onOpenMenu(item: Music) {
when (item) {
is Artist -> listModel.openMenu(R.menu.parent, item)
@ -159,26 +105,45 @@ class GenreDetailFragment :
}
}
override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
}
override fun onShuffle() {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
}
override fun onOpenSortMenu() {
findNavController().navigateSafe(GenreDetailFragmentDirections.sort())
}
private fun updatePlaylist(genre: Genre?) {
private fun updateGenre(genre: Genre?) {
if (genre == null) {
logD("No genre to show, navigating away")
L.d("No genre to show, navigating away")
findNavController().navigateUp()
return
}
requireBinding().detailNormalToolbar.title = genre.name.resolve(requireContext())
genreHeaderAdapter.setParent(genre)
val binding = requireBinding()
val context = requireContext()
val name = genre.name.resolve(context)
binding.detailToolbarTitle.text = name
binding.detailCover.bind(genre)
binding.detailType.text = context.getString(R.string.lbl_genre)
binding.detailName.text = genre.name.resolve(context)
// Nothing about a genre is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
// The song and artist count of the genre maps to the info text.
binding.detailInfo.text =
context.getString(
R.string.fmt_two,
context.getPlural(R.plurals.fmt_artist_count, genre.artists.size),
context.getPlural(R.plurals.fmt_song_count, genre.songs.size))
binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
}
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
}
binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
}
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
}
updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
}
private fun updateList(list: List<Item>) {
@ -188,7 +153,7 @@ class GenreDetailFragment :
private fun handleShow(show: Show?) {
when (show) {
is Show.SongDetails -> {
logD("Navigating to ${show.song}")
L.d("Navigating to ${show.song}")
findNavController()
.navigateSafe(GenreDetailFragmentDirections.showSong(show.song.uid))
}
@ -196,7 +161,7 @@ class GenreDetailFragment :
// Songs should be scrolled to if the album matches, or a new detail
// fragment should be launched otherwise.
is Show.SongAlbumDetails -> {
logD("Navigating to the album of ${show.song}")
L.d("Navigating to the album of ${show.song}")
findNavController()
.navigateSafe(GenreDetailFragmentDirections.showAlbum(show.song.album.uid))
}
@ -204,29 +169,29 @@ class GenreDetailFragment :
// If the album matches, no need to do anything. Otherwise launch a new
// detail fragment.
is Show.AlbumDetails -> {
logD("Navigating to ${show.album}")
L.d("Navigating to ${show.album}")
findNavController()
.navigateSafe(GenreDetailFragmentDirections.showAlbum(show.album.uid))
}
// Always launch a new ArtistDetailFragment.
is Show.ArtistDetails -> {
logD("Navigating to ${show.artist}")
L.d("Navigating to ${show.artist}")
findNavController()
.navigateSafe(GenreDetailFragmentDirections.showArtist(show.artist.uid))
}
is Show.SongArtistDecision -> {
logD("Navigating to artist choices for ${show.song}")
L.d("Navigating to artist choices for ${show.song}")
findNavController()
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.song.uid))
}
is Show.AlbumArtistDecision -> {
logD("Navigating to artist choices for ${show.album}")
L.d("Navigating to artist choices for ${show.album}")
findNavController()
.navigateSafe(GenreDetailFragmentDirections.showArtistChoices(show.album.uid))
}
is Show.GenreDetails -> {
logD("Navigated to this genre")
L.d("Navigated to this genre")
detailModel.toShow.consume()
}
is Show.PlaylistDetails -> {
@ -267,7 +232,7 @@ class GenreDetailFragment :
val directions =
when (decision) {
is PlaylistDecision.Add -> {
logD("Adding ${decision.songs.size} songs to a playlist")
L.d("Adding ${decision.songs.size} songs to a playlist")
GenreDetailFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray())
}
@ -306,7 +271,7 @@ class GenreDetailFragment :
val directions =
when (decision) {
is PlaybackDecision.PlayFromArtist -> {
logD("Launching play from artist dialog for $decision")
L.d("Launching play from artist dialog for $decision")
GenreDetailFragmentDirections.playFromArtist(decision.song.uid)
}
is PlaybackDecision.PlayFromGenre -> error("Unexpected playback decision $decision")

View file

@ -19,51 +19,41 @@
package org.oxycblt.auxio.detail
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.activityViewModels
import androidx.core.view.isVisible
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding
import org.oxycblt.auxio.detail.header.DetailHeaderAdapter
import org.oxycblt.auxio.detail.header.PlaylistDetailHeaderAdapter
import org.oxycblt.auxio.detail.list.PlaylistDetailListAdapter
import org.oxycblt.auxio.detail.list.PlaylistDragCallback
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.DialogAwareNavigationListener
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.playlist.m3u.M3U
import timber.log.Timber as L
/**
* A [ListFragment] that shows information for a particular [Playlist].
@ -72,35 +62,17 @@ import org.oxycblt.auxio.util.unlikelyToBeNull
*/
@AndroidEntryPoint
class PlaylistDetailFragment :
ListFragment<Song, FragmentDetailBinding>(),
DetailHeaderAdapter.Listener,
PlaylistDetailListAdapter.Listener {
private val detailModel: DetailViewModel by activityViewModels()
override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
DetailFragment<Playlist, Song>(), PlaylistDetailListAdapter.Listener {
// Information about what playlist to display is initially within the navigation arguments
// as a UID, as that is the only safe way to parcel an playlist.
private val args: PlaylistDetailFragmentArgs by navArgs()
private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this)
private val playlistListAdapter = PlaylistDetailListAdapter(this)
private var touchHelper: ItemTouchHelper? = null
private var editNavigationListener: DialogAwareNavigationListener? = null
private var getContentLauncher: ActivityResultLauncher<String>? = null
private var pendingImportTarget: Playlist? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentDetailBinding.inflate(inflater)
override fun getSelectionToolbar(binding: FragmentDetailBinding) =
binding.detailSelectionToolbar
override fun getDetailListAdapter() = playlistListAdapter
override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) {
super.onBindingCreated(binding, savedInstanceState)
@ -110,52 +82,31 @@ class PlaylistDetailFragment :
getContentLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
logW("No URI returned from file picker")
L.w("No URI returned from file picker")
return@registerForActivityResult
}
logD("Received playlist URI $uri")
L.d("Received playlist URI $uri")
musicModel.importPlaylist(uri, pendingImportTarget)
}
// --- UI SETUP ---
binding.detailNormalToolbar.apply {
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener(this@PlaylistDetailFragment)
overrideOnOverflowMenuClick {
listModel.openMenu(
R.menu.detail_playlist, unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailEditToolbar.apply {
setNavigationOnClickListener { detailModel.dropPlaylistEdit() }
setOnMenuItemClickListener(this@PlaylistDetailFragment)
}
binding.detailRecycler.apply {
adapter = ConcatAdapter(playlistHeaderAdapter, playlistListAdapter)
touchHelper =
ItemTouchHelper(PlaylistDragCallback(detailModel)).also {
it.attachToRecyclerView(this)
}
(layoutManager as GridLayoutManager).setFullWidthLookup {
if (it != 0) {
val item =
detailModel.playlistSongList.value.getOrElse(it - 1) {
return@setFullWidthLookup false
}
item is Divider || item is Header
} else {
true
}
}
it.attachToRecyclerView(binding.detailRecycler)
}
// --- VIEWMODEL SETUP ---
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setPlaylist(args.playlistUid)
collectImmediately(detailModel.currentPlaylist, ::updatePlaylist)
collectImmediately(
detailModel.currentPlaylist, detailModel.editedPlaylist, ::updatePlaylist)
collectImmediately(detailModel.playlistSongList, ::updateList)
collectImmediately(detailModel.editedPlaylist, ::updateEditedList)
collect(detailModel.toShow.flow, ::handleShow)
@ -210,41 +161,97 @@ class PlaylistDetailFragment :
playbackModel.play(item, detailModel.playInPlaylistWith)
}
override fun onStartEdit() {
detailModel.startPlaylistEdit()
}
override fun onPickUp(viewHolder: RecyclerView.ViewHolder) {
requireNotNull(touchHelper) { "ItemTouchHelper was not available" }.startDrag(viewHolder)
}
override fun onOpenParentMenu() {
listModel.openMenu(
R.menu.detail_playlist, unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onOpenMenu(item: Song) {
listModel.openMenu(R.menu.playlist_song, item, detailModel.playInPlaylistWith)
}
override fun onPlay() {
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onShuffle() {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
override fun onStartEdit() {
detailModel.startPlaylistEdit()
}
override fun onOpenSortMenu() {
findNavController().navigateSafe(PlaylistDetailFragmentDirections.sort())
}
private fun updatePlaylist(playlist: Playlist?) {
private fun updatePlaylist(playlist: Playlist?, editedPlaylist: List<Song>?) {
if (playlist == null) {
// Playlist we were showing no longer exists.
findNavController().navigateUp()
return
}
val binding = requireBinding()
binding.detailNormalToolbar.title = playlist.name.resolve(requireContext())
binding.detailToolbarTitle.text = playlist.name.resolve(requireContext())
binding.detailEditToolbar.title =
getString(R.string.fmt_editing, playlist.name.resolve(requireContext()))
playlistHeaderAdapter.setParent(playlist)
if (editedPlaylist != null) {
L.d("Binding edited playlist image")
binding.detailCover.bind(
editedPlaylist,
binding.context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24)
} else {
binding.detailCover.bind(playlist)
}
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
binding.detailName.text = playlist.name.resolve(binding.context)
// Nothing about a playlist is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
val songs = editedPlaylist ?: playlist.songs
val durationMs = editedPlaylist?.sumOf { it.durationMs } ?: playlist.durationMs
// The song count of the playlist maps to the info text.
binding.detailInfo.text =
if (songs.isNotEmpty()) {
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_song_count, songs.size),
durationMs.formatDurationMs(true))
} else {
binding.context.getString(R.string.def_song_count)
}
val playable = playlist.songs.isNotEmpty() && editedPlaylist == null
if (!playable) {
L.d("Playlist is being edited or is empty, disabling playback options")
}
binding.detailPlayButton?.apply {
isEnabled = playable
setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailToolbarPlay.apply {
isEnabled = playable
setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailShuffleButton?.apply {
isEnabled = playable
setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailToolbarShuffle.apply {
isEnabled = playable
setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
}
private fun updateList(list: List<Item>) {
@ -253,11 +260,10 @@ class PlaylistDetailFragment :
private fun updateEditedList(editedPlaylist: List<Song>?) {
playlistListAdapter.setEditing(editedPlaylist != null)
playlistHeaderAdapter.setEditedPlaylist(editedPlaylist)
listModel.dropSelection()
if (editedPlaylist != null) {
logD("Updating save button state")
L.d("Updating save button state")
requireBinding().detailEditToolbar.menu.findItem(R.id.action_save).apply {
isEnabled = editedPlaylist != detailModel.currentPlaylist.value?.songs
}
@ -269,38 +275,38 @@ class PlaylistDetailFragment :
private fun handleShow(show: Show?) {
when (show) {
is Show.SongDetails -> {
logD("Navigating to ${show.song}")
L.d("Navigating to ${show.song}")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showSong(show.song.uid))
}
is Show.SongAlbumDetails -> {
logD("Navigating to the album of ${show.song}")
L.d("Navigating to the album of ${show.song}")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.song.album.uid))
}
is Show.AlbumDetails -> {
logD("Navigating to ${show.album}")
L.d("Navigating to ${show.album}")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showAlbum(show.album.uid))
}
is Show.ArtistDetails -> {
logD("Navigating to ${show.artist}")
L.d("Navigating to ${show.artist}")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showArtist(show.artist.uid))
}
is Show.SongArtistDecision -> {
logD("Navigating to artist choices for ${show.song}")
L.d("Navigating to artist choices for ${show.song}")
findNavController()
.navigateSafe(PlaylistDetailFragmentDirections.showArtistChoices(show.song.uid))
}
is Show.AlbumArtistDecision -> {
logD("Navigating to artist choices for ${show.album}")
L.d("Navigating to artist choices for ${show.album}")
findNavController()
.navigateSafe(
PlaylistDetailFragmentDirections.showArtistChoices(show.album.uid))
}
is Show.PlaylistDetails -> {
logD("Navigated to this playlist")
L.d("Navigated to this playlist")
detailModel.toShow.consume()
}
is Show.GenreDetails -> {
@ -341,7 +347,7 @@ class PlaylistDetailFragment :
val directions =
when (decision) {
is PlaylistDecision.Import -> {
logD("Importing playlist")
L.d("Importing playlist")
pendingImportTarget = decision.target
requireNotNull(getContentLauncher) {
"Content picker launcher was not available"
@ -351,7 +357,7 @@ class PlaylistDetailFragment :
return
}
is PlaylistDecision.Rename -> {
logD("Renaming ${decision.playlist}")
L.d("Renaming ${decision.playlist}")
PlaylistDetailFragmentDirections.renamePlaylist(
decision.playlist.uid,
decision.template,
@ -359,15 +365,15 @@ class PlaylistDetailFragment :
decision.reason)
}
is PlaylistDecision.Export -> {
logD("Exporting ${decision.playlist}")
L.d("Exporting ${decision.playlist}")
PlaylistDetailFragmentDirections.exportPlaylist(decision.playlist.uid)
}
is PlaylistDecision.Delete -> {
logD("Deleting ${decision.playlist}")
L.d("Deleting ${decision.playlist}")
PlaylistDetailFragmentDirections.deletePlaylist(decision.playlist.uid)
}
is PlaylistDecision.Add -> {
logD("Adding ${decision.songs.size} songs to a playlist")
L.d("Adding ${decision.songs.size} songs to a playlist")
PlaylistDetailFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray())
}
@ -393,11 +399,11 @@ class PlaylistDetailFragment :
val directions =
when (decision) {
is PlaybackDecision.PlayFromArtist -> {
logD("Launching play from artist dialog for $decision")
L.d("Launching play from artist dialog for $decision")
PlaylistDetailFragmentDirections.playFromArtist(decision.song.uid)
}
is PlaybackDecision.PlayFromGenre -> {
logD("Launching play from artist dialog for $decision")
L.d("Launching play from artist dialog for $decision")
PlaylistDetailFragmentDirections.playFromGenre(decision.song.uid)
}
}
@ -408,15 +414,15 @@ class PlaylistDetailFragment :
val id =
when {
detailModel.editedPlaylist.value != null -> {
logD("Currently editing playlist, showing edit toolbar")
L.d("Currently editing playlist, showing edit toolbar")
R.id.detail_edit_toolbar
}
listModel.selected.value.isNotEmpty() -> {
logD("Currently selecting, showing selection toolbar")
L.d("Currently selecting, showing selection toolbar")
R.id.detail_selection_toolbar
}
else -> {
logD("Using normal toolbar")
L.d("Using normal toolbar")
R.id.detail_normal_toolbar
}
}

View file

@ -18,9 +18,7 @@
package org.oxycblt.auxio.detail
import android.content.Context
import android.os.Bundle
import android.text.format.Formatter
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels
@ -32,17 +30,10 @@ import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.detail.list.SongProperty
import org.oxycblt.auxio.detail.list.SongPropertyAdapter
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.replaygain.formatDb
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.auxio.util.logD
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* A [ViewBindingMaterialDialogFragment] that shows information about a Song.
@ -71,74 +62,19 @@ class SongDetailDialog : ViewBindingMaterialDialogFragment<DialogSongDetailBindi
// DetailViewModel handles most initialization from the navigation argument.
detailModel.setSong(args.songUid)
detailModel.toShow.consume()
collectImmediately(detailModel.currentSong, detailModel.songAudioProperties, ::updateSong)
collectImmediately(detailModel.currentSong, ::updateSong)
collectImmediately(detailModel.currentSongProperties, ::updateSongProperties)
}
private fun updateSong(song: Song?, info: AudioProperties?) {
private fun updateSong(song: Song?) {
L.d("No song to show, navigating away")
if (song == null) {
logD("No song to show, navigating away")
findNavController().navigateUp()
return
}
if (info != null) {
val context = requireContext()
detailAdapter.update(
buildList {
add(SongProperty(R.string.lbl_name, song.zipName(context)))
add(SongProperty(R.string.lbl_album, song.album.zipName(context)))
add(SongProperty(R.string.lbl_artists, song.artists.zipNames(context)))
add(SongProperty(R.string.lbl_genres, song.genres.resolveNames(context)))
song.date?.let { add(SongProperty(R.string.lbl_date, it.resolve(context))) }
song.track?.let {
add(SongProperty(R.string.lbl_track, getString(R.string.fmt_number, it)))
}
song.disc?.let {
val formattedNumber = getString(R.string.fmt_number, it.number)
val zipped =
if (it.name != null) {
getString(R.string.fmt_zipped_names, formattedNumber, it.name)
} else {
formattedNumber
}
add(SongProperty(R.string.lbl_disc, zipped))
}
add(SongProperty(R.string.lbl_path, song.path.resolve(context)))
info.resolvedMimeType.resolveName(context)?.let {
add(SongProperty(R.string.lbl_format, it))
}
add(
SongProperty(
R.string.lbl_size, Formatter.formatFileSize(context, song.size)))
add(SongProperty(R.string.lbl_duration, song.durationMs.formatDurationMs(true)))
info.bitrateKbps?.let {
add(SongProperty(R.string.lbl_bitrate, getString(R.string.fmt_bitrate, it)))
}
info.sampleRateHz?.let {
add(
SongProperty(
R.string.lbl_sample_rate, getString(R.string.fmt_sample_rate, it)))
}
song.replayGainAdjustment.track?.let {
add(SongProperty(R.string.lbl_replaygain_track, it.formatDb(context)))
}
song.replayGainAdjustment.album?.let {
add(SongProperty(R.string.lbl_replaygain_album, it.formatDb(context)))
}
},
UpdateInstructions.Replace(0))
}
}
private fun <T : Music> T.zipName(context: Context): String {
val name = name
return if (name is Name.Known && name.sort != null) {
getString(R.string.fmt_zipped_names, name.resolve(context), name.sort)
} else {
name.resolve(context)
private fun updateSongProperties(songProperties: List<SongProperty>) {
detailAdapter.update(songProperties, UpdateInstructions.Replace(0))
}
}
private fun <T : Music> List<T>.zipNames(context: Context) =
concatLocalized(context) { it.zipName(context) }
}

View file

@ -25,9 +25,10 @@ import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Artist
/**
* A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with

View file

@ -23,14 +23,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.device.DeviceLibrary
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Library
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* A [ViewModel] that stores choice information for [ShowArtistDialog], and possibly others in the
@ -57,10 +56,10 @@ class DetailPickerViewModel @Inject constructor(private val musicRepository: Mus
override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.deviceLibrary) return
val deviceLibrary = musicRepository.deviceLibrary ?: return
val library = musicRepository.library ?: return
// Need to sanitize different items depending on the current set of choices.
_artistChoices.value = _artistChoices.value?.sanitize(deviceLibrary)
logD("Updated artist choices: ${_artistChoices.value}")
_artistChoices.value = _artistChoices.value?.sanitize(library)
L.d("Updated artist choices: ${_artistChoices.value}")
}
/**
@ -69,20 +68,20 @@ class DetailPickerViewModel @Inject constructor(private val musicRepository: Mus
* @param itemUid The [Music.UID] of the item to show. Must be a [Song] or [Album].
*/
fun setArtistChoiceUid(itemUid: Music.UID) {
logD("Opening navigation choices for $itemUid")
L.d("Opening navigation choices for $itemUid")
// Support Songs and Albums, which have parent artists.
_artistChoices.value =
when (val music = musicRepository.find(itemUid)) {
is Song -> {
logD("Creating navigation choices for song")
L.d("Creating navigation choices for song")
ArtistShowChoices.FromSong(music)
}
is Album -> {
logD("Creating navigation choices for album")
L.d("Creating navigation choices for album")
ArtistShowChoices.FromAlbum(music)
}
else -> {
logW("Given song/album UID was invalid")
L.w("Given song/album UID was invalid")
null
}
}
@ -99,16 +98,15 @@ sealed interface ArtistShowChoices {
val uid: Music.UID
/** The current [Artist] choices. */
val choices: List<Artist>
/** Sanitize this instance with a [DeviceLibrary]. */
fun sanitize(newLibrary: DeviceLibrary): ArtistShowChoices?
/** Sanitize this instance with a [Library]. */
fun sanitize(newLibrary: Library): ArtistShowChoices?
/** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */
class FromSong(val song: Song) : ArtistShowChoices {
override val uid = song.uid
override val choices = song.artists
override fun sanitize(newLibrary: DeviceLibrary) =
newLibrary.findSong(uid)?.let { FromSong(it) }
override fun sanitize(newLibrary: Library) = newLibrary.findSong(uid)?.let { FromSong(it) }
}
/** Backing implementation of [ArtistShowChoices] that is based on an [Album]. */
@ -116,7 +114,7 @@ sealed interface ArtistShowChoices {
override val uid = album.uid
override val choices = album.artists
override fun sanitize(newLibrary: DeviceLibrary) =
override fun sanitize(newLibrary: Library) =
newLibrary.findAlbum(uid)?.let { FromAlbum(it) }
}
}

View file

@ -32,10 +32,10 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.musikr.Artist
import timber.log.Timber as L
/**
* A picker [ViewBindingMaterialDialogFragment] intended for when the [Artist] to show is ambiguous.
@ -85,7 +85,7 @@ class ShowArtistDialog :
private fun updateChoices(choices: ArtistShowChoices?) {
if (choices == null) {
logD("No choices to show, navigating away")
L.d("No choices to show, navigating away")
findNavController().navigateUp()
return
}

View file

@ -1,114 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* AlbumDetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Album] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Album, AlbumDetailHeaderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
AlbumDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: AlbumDetailHeaderViewHolder, parent: Album) =
holder.bind(parent, listener)
/** An extended listener for [DetailHeaderAdapter] implementations. */
interface Listener : DetailHeaderAdapter.Listener {
/**
* Called when the artist name in the [Album] header was clicked, requesting navigation to
* it's parent artist.
*/
fun onNavigateToParentArtist()
}
}
/**
* A [RecyclerView.ViewHolder] that displays the [Album] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param album The new [Album] to bind.
* @param listener A [AlbumDetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(album: Album, listener: AlbumDetailHeaderAdapter.Listener) {
binding.detailCover.bind(album)
// The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = binding.context.getString(album.releaseType.stringRes)
binding.detailName.text = album.name.resolve(binding.context)
// Artist name maps to the subhead text
binding.detailSubhead.apply {
text = album.artists.resolveNames(context)
// Add a QoL behavior where navigation to the artist will occur if the artist
// name is pressed.
setOnClickListener { listener.onNavigateToParentArtist() }
}
// Date, song count, and duration map to the info text
binding.detailInfo.apply {
// Fall back to a friendlier "No date" text if the album doesn't have date information
val date = album.dates?.resolveDate(context) ?: context.getString(R.string.def_date)
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
val duration = album.durationMs.formatDurationMs(true)
text = context.getString(R.string.fmt_three, date, songCount, duration)
}
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
AlbumDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -1,120 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* ArtistDetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/**
* A [DetailHeaderAdapter] that shows [Artist] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Artist, ArtistDetailHeaderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ArtistDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: ArtistDetailHeaderViewHolder, parent: Artist) =
holder.bind(parent, listener)
}
/**
* A [RecyclerView.ViewHolder] that displays the [Artist] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param artist The new [Artist] to bind.
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(artist: Artist, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(artist)
binding.detailType.text = binding.context.getString(R.string.lbl_artist)
binding.detailName.text = artist.name.resolve(binding.context)
// Song and album counts map to the info
binding.detailInfo.text =
binding.context.getString(
R.string.fmt_two,
if (artist.explicitAlbums.isNotEmpty()) {
binding.context.getPlural(R.plurals.fmt_album_count, artist.explicitAlbums.size)
} else {
binding.context.getString(R.string.def_album_count)
},
if (artist.songs.isNotEmpty()) {
binding.context.getPlural(R.plurals.fmt_song_count, artist.songs.size)
} else {
binding.context.getString(R.string.def_song_count)
})
if (artist.songs.isNotEmpty()) {
// Information about the artist's genre(s) map to the sub-head text
binding.detailSubhead.apply {
isVisible = true
text = artist.genres.resolveNames(context)
}
// In the case that this header used to he configured to have no songs,
// we want to reset the visibility of all information that was hidden.
binding.detailPlayButton.isVisible = true
binding.detailShuffleButton.isVisible = true
} else {
// The artist does not have any songs, so hide functionality that makes no sense.
// ex. Play and Shuffle, Song Counts, and Genre Information.
// Artists are always guaranteed to have albums however, so continue to show those.
logD("Artist is empty, disabling genres and playback")
binding.detailSubhead.isVisible = false
binding.detailPlayButton.isEnabled = false
binding.detailShuffleButton.isEnabled = false
}
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
ArtistDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -1,84 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* DetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.util.logD
/**
* A [RecyclerView.Adapter] that implements shared behavior between each parent header view.
*
* @author Alexander Capehart (OxygenCobalt)
*/
abstract class DetailHeaderAdapter<T : MusicParent, VH : RecyclerView.ViewHolder> :
RecyclerView.Adapter<VH>() {
private var currentParent: T? = null
final override fun getItemCount() = 1
final override fun onBindViewHolder(holder: VH, position: Int) =
onBindHeader(holder, requireNotNull(currentParent))
/**
* Bind the created header [RecyclerView.ViewHolder] with the current [parent].
*
* @param holder The [RecyclerView.ViewHolder] to bind.
* @param parent The current [MusicParent] to bind.
*/
abstract fun onBindHeader(holder: VH, parent: T)
/**
* Update the [MusicParent] shown in the header.
*
* @param parent The new [MusicParent] to show.
*/
fun setParent(parent: T) {
logD("Updating parent [old: $currentParent new: $parent]")
currentParent = parent
rebindParent()
}
/**
* Forces the parent [RecyclerView.ViewHolder] to rebind as soon as possible, with no animation.
*/
protected fun rebindParent() {
logD("Rebinding parent")
notifyItemChanged(0, PAYLOAD_UPDATE_HEADER)
}
/** A listener for [DetailHeaderAdapter] implementations. */
interface Listener {
/**
* Called when the play button in a detail header is pressed, requesting that the current
* item should be played.
*/
fun onPlay()
/**
* Called when the shuffle button in a detail header is pressed, requesting that the current
* item should be shuffled
*/
fun onShuffle()
}
private companion object {
val PAYLOAD_UPDATE_HEADER = Any()
}
}

View file

@ -1,88 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* GenreDetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
/**
* A [DetailHeaderAdapter] that shows [Genre] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Genre, GenreDetailHeaderViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
GenreDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: GenreDetailHeaderViewHolder, parent: Genre) =
holder.bind(parent, listener)
}
/**
* A [RecyclerView.ViewHolder] that displays the [Genre] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param genre The new [Genre] to bind.
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(genre: Genre, listener: DetailHeaderAdapter.Listener) {
binding.detailCover.bind(genre)
binding.detailType.text = binding.context.getString(R.string.lbl_genre)
binding.detailName.text = genre.name.resolve(binding.context)
// Nothing about a genre is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
// The song and artist count of the genre maps to the info text.
binding.detailInfo.text =
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_artist_count, genre.artists.size),
binding.context.getPlural(R.plurals.fmt_song_count, genre.songs.size))
binding.detailPlayButton.setOnClickListener { listener.onPlay() }
binding.detailShuffleButton.setOnClickListener { listener.onShuffle() }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
GenreDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -1,141 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistDetailHeaderAdapter.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.detail.header
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemDetailHeaderBinding
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
/**
* A [DetailHeaderAdapter] that shows [Playlist] information.
*
* @param listener [DetailHeaderAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDetailHeaderAdapter(private val listener: Listener) :
DetailHeaderAdapter<Playlist, PlaylistDetailHeaderViewHolder>() {
private var editedPlaylist: List<Song>? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
PlaylistDetailHeaderViewHolder.from(parent)
override fun onBindHeader(holder: PlaylistDetailHeaderViewHolder, parent: Playlist) =
holder.bind(parent, editedPlaylist, listener)
/**
* Indicate to this adapter that editing is ongoing with the current state of the editing
* process. This will make the header immediately update to reflect information about the edited
* playlist.
*/
fun setEditedPlaylist(songs: List<Song>?) {
if (editedPlaylist == songs) {
// Nothing to do.
return
}
logD("Updating editing state [old: ${editedPlaylist?.size} new: ${songs?.size}")
editedPlaylist = songs
rebindParent()
}
}
/**
* A [RecyclerView.ViewHolder] that displays the [Playlist] header in the detail view. Use [from] to
* create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistDetailHeaderViewHolder
private constructor(private val binding: ItemDetailHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param playlist The new [Playlist] to bind.
* @param editedPlaylist The current edited state of the playlist, if it exists.
* @param listener A [DetailHeaderAdapter.Listener] to bind interactions to.
*/
fun bind(
playlist: Playlist,
editedPlaylist: List<Song>?,
listener: DetailHeaderAdapter.Listener
) {
if (editedPlaylist != null) {
logD("Binding edited playlist image")
binding.detailCover.bind(
editedPlaylist,
binding.context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24)
} else {
binding.detailCover.bind(playlist)
}
binding.detailType.text = binding.context.getString(R.string.lbl_playlist)
binding.detailName.text = playlist.name.resolve(binding.context)
// Nothing about a playlist is applicable to the sub-head text.
binding.detailSubhead.isVisible = false
val songs = editedPlaylist ?: playlist.songs
val durationMs = editedPlaylist?.sumOf { it.durationMs } ?: playlist.durationMs
// The song count of the playlist maps to the info text.
binding.detailInfo.text =
if (songs.isNotEmpty()) {
binding.context.getString(
R.string.fmt_two,
binding.context.getPlural(R.plurals.fmt_song_count, songs.size),
durationMs.formatDurationMs(true))
} else {
binding.context.getString(R.string.def_song_count)
}
val playable = playlist.songs.isNotEmpty() && editedPlaylist == null
if (!playable) {
logD("Playlist is being edited or is empty, disabling playback options")
}
binding.detailPlayButton.apply {
isEnabled = playable
setOnClickListener { listener.onPlay() }
}
binding.detailShuffleButton.apply {
isEnabled = playable
setOnClickListener { listener.onShuffle() }
}
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
PlaylistDetailHeaderViewHolder(ItemDetailHeaderBinding.inflate(parent.context.inflater))
}
}

View file

@ -24,21 +24,25 @@ import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDivider
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemAlbumSongBinding
import org.oxycblt.auxio.databinding.ItemDiscHeaderBinding
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.tag.Disc
/**
* An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
@ -52,6 +56,7 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
when (getItem(position)) {
// Support sub-headers for each disc, and special album songs.
is DiscHeader -> DiscHeaderViewHolder.VIEW_TYPE
is DiscDivider -> DiscDividerViewHolder.VIEW_TYPE
is Song -> AlbumSongViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
}
@ -59,6 +64,7 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
when (viewType) {
DiscHeaderViewHolder.VIEW_TYPE -> DiscHeaderViewHolder.from(parent)
DiscDividerViewHolder.VIEW_TYPE -> DiscDividerViewHolder.from(parent)
AlbumSongViewHolder.VIEW_TYPE -> AlbumSongViewHolder.from(parent)
else -> super.onCreateViewHolder(parent, viewType)
}
@ -79,6 +85,8 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
when {
oldItem is Disc && newItem is Disc ->
DiscHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is DiscDivider && newItem is DiscDivider ->
DiscDividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is Song && newItem is Song ->
AlbumSongViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
@ -94,7 +102,9 @@ class AlbumDetailListAdapter(private val listener: Listener<Song>) :
*
* @author Alexander Capehart (OxygenCobalt)
*/
data class DiscHeader(val inner: Disc?) : Item
data class DiscHeader(val inner: Disc?) : Header
data class DiscDivider(override val anchor: DiscHeader?) : Divider<DiscHeader>
/**
* A [RecyclerView.ViewHolder] that displays a [DiscHeader] to delimit different disc groups. Use
@ -111,16 +121,10 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
*/
fun bind(discHeader: DiscHeader) {
val disc = discHeader.inner
if (disc != null) {
binding.discNumber.text = binding.context.getString(R.string.fmt_disc_no, disc.number)
binding.discNumber.text = disc.resolve(binding.context)
binding.discName.apply {
text = disc.name
isGone = disc.name == null
}
} else {
logD("Disc is null, defaulting to no disc")
binding.discNumber.text = binding.context.getString(R.string.def_disc)
binding.discName.isGone = true
text = disc?.name
isGone = disc?.name == null
}
}
@ -146,6 +150,42 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
}
}
/**
* A [RecyclerView.ViewHolder] that displays a [DiscHeader]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class DiscDividerViewHolder private constructor(divider: MaterialDivider) :
RecyclerView.ViewHolder(divider) {
init {
divider.dividerColor =
divider.context
.getAttrColorCompat(com.google.android.material.R.attr.colorOutlineVariant)
.defaultColor
}
companion object {
/** Unique ID for this ViewHolder type. */
const val VIEW_TYPE = IntegerTable.VIEW_TYPE_DISC_DIVIDER
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) = DiscDividerViewHolder(MaterialDivider(parent.context))
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<DiscDivider>() {
override fun areContentsTheSame(oldItem: DiscDivider, newItem: DiscDivider) =
oldItem.anchor == newItem.anchor
}
}
}
/**
* A [RecyclerView.ViewHolder] that displays a [Song] in the context of an [Album]. Use [from] to
* create an instance.

View file

@ -29,12 +29,13 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
/**
* A [DetailListAdapter] implementing the header and sub-items for the [Artist] detail view.
@ -104,8 +105,7 @@ private class ArtistAlbumViewHolder private constructor(private val binding: Ite
binding.parentName.text = album.name.resolve(binding.context)
binding.parentInfo.text =
// Fall back to a friendlier "No date" text if the album doesn't have date information
album.dates?.resolveDate(binding.context)
?: binding.context.getString(R.string.def_date)
album.dates?.resolve(binding.context) ?: binding.context.getString(R.string.def_date)
}
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {

View file

@ -27,17 +27,17 @@ import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.databinding.ItemSortHeaderBinding
import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder
import org.oxycblt.auxio.list.recycler.DividerViewHolder
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Music
/**
* A [RecyclerView.Adapter] that implements shared behavior between lists of child items in the
@ -55,7 +55,7 @@ abstract class DetailListAdapter(
override fun getItemViewType(position: Int) =
when (getItem(position)) {
// Implement support for headers and sort headers
is Divider -> DividerViewHolder.VIEW_TYPE
is PlainDivider -> DividerViewHolder.VIEW_TYPE
is BasicHeader -> BasicHeaderViewHolder.VIEW_TYPE
is SortHeader -> SortHeaderViewHolder.VIEW_TYPE
else -> super.getItemViewType(position)
@ -91,7 +91,7 @@ abstract class DetailListAdapter(
object : SimpleDiffCallback<Item>() {
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return when {
oldItem is Divider && newItem is Divider ->
oldItem is PlainDivider && newItem is PlainDivider ->
DividerViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
oldItem is BasicHeader && newItem is BasicHeader ->
BasicHeaderViewHolder.DIFF_CALLBACK.areContentsTheSame(oldItem, newItem)
@ -110,7 +110,7 @@ abstract class DetailListAdapter(
* @param titleRes The string resource to use as the header title
* @author Alexander Capehart (OxygenCobalt)
*/
data class SortHeader(@StringRes override val titleRes: Int) : Header
data class SortHeader(@StringRes override val titleRes: Int) : PlainHeader
/**
* A [RecyclerView.ViewHolder] that displays a [SortHeader] and it's actions. Use [from] to create

View file

@ -24,10 +24,10 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
/**
* A [DetailListAdapter] implementing the header and sub-items for the [Genre] detail view.

View file

@ -30,25 +30,24 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.R as MR
import com.google.android.material.shape.MaterialShapeDrawable
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemEditHeaderBinding
import org.oxycblt.auxio.databinding.ItemEditableSongBinding
import org.oxycblt.auxio.list.EditableListListener
import org.oxycblt.auxio.list.Header
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.list.adapter.PlayingIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.MaterialDragCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* A [DetailListAdapter] implementing the header, sub-items, and editing state for the [Playlist]
@ -99,9 +98,9 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
// Nothing to do.
return
}
logD("Updating editing state [old: $isEditing new: $editing]")
L.d("Updating editing state [old: $isEditing new: $editing]")
this.isEditing = editing
notifyItemRangeChanged(1, currentList.size - 1, PAYLOAD_EDITING_CHANGED)
notifyItemRangeChanged(0, currentList.size, PAYLOAD_EDITING_CHANGED)
}
/** An extended [DetailListAdapter.Listener] for [PlaylistDetailListAdapter]. */
@ -142,12 +141,12 @@ class PlaylistDetailListAdapter(private val listener: Listener) :
}
/**
* A [Header] variant that displays an edit button.
* A [PlainHeader] variant that displays an edit button.
*
* @param titleRes The string resource to use as the header title
* @author Alexander Capehart (OxygenCobalt)
*/
data class EditHeader(@StringRes override val titleRes: Int) : Header
data class EditHeader(@StringRes override val titleRes: Int) : PlainHeader
/**
* Displays an [EditHeader] and it's actions. Use [from] to create an instance.
@ -232,8 +231,7 @@ private constructor(private val binding: ItemEditableSongBinding) :
override val delete = binding.background
override val background =
MaterialShapeDrawable.createWithElevationOverlay(binding.root.context).apply {
fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurface)
elevation = binding.context.getDimen(R.dimen.elevation_normal)
fillColor = binding.context.getAttrColorCompat(MR.attr.colorSurfaceContainerHigh)
alpha = 0
}

View file

@ -18,17 +18,26 @@
package org.oxycblt.auxio.detail.list
import android.text.format.Formatter
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.replaygain.formatDb
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.fs.Format
import org.oxycblt.musikr.fs.Path
import org.oxycblt.musikr.tag.Date
/**
* An adapter for [SongProperty] instances.
@ -53,7 +62,31 @@ class SongPropertyAdapter :
* @param value The value of the property.
* @author Alexander Capehart (OxygenCobalt)
*/
data class SongProperty(@StringRes val name: Int, val value: String) : Item
data class SongProperty(@StringRes val name: Int, val value: Value) {
sealed interface Value {
data class MusicName(val music: Music) : Value
data class MusicNames(val name: List<Music>) : Value
data class Number(val value: Int, val subtitle: String?) : Value
data class ItemDate(val date: Date) : Value
data class ItemPath(val path: Path) : Value
data class Size(val sizeBytes: Long) : Value
data class Duration(val durationMs: Long) : Value
data class ItemFormat(val format: Format) : Value
data class Bitrate(val kbps: Int) : Value
data class SampleRate(val hz: Int) : Value
data class Decibels(val value: Float) : Value
}
}
/**
* A [RecyclerView.ViewHolder] that displays a [SongProperty]. Use [from] to create an instance.
@ -65,7 +98,58 @@ class SongPropertyViewHolder private constructor(private val binding: ItemSongPr
fun bind(property: SongProperty) {
val context = binding.context
binding.propertyName.hint = context.getString(property.name)
binding.propertyValue.setText(property.value)
when (property.value) {
is SongProperty.Value.MusicName -> {
val music = property.value.music
binding.propertyValue.setText(music.name.resolve(context))
}
is SongProperty.Value.MusicNames -> {
val names = property.value.name.resolveNames(context)
binding.propertyValue.setText(names)
}
is SongProperty.Value.Number -> {
val value = context.getString(R.string.fmt_number, property.value.value)
val subtitle = property.value.subtitle
binding.propertyValue.setText(
if (subtitle != null) {
context.getString(R.string.fmt_zipped_names, value, subtitle)
} else {
value
})
}
is SongProperty.Value.ItemDate -> {
val date = property.value.date
binding.propertyValue.setText(date.resolve(context))
}
is SongProperty.Value.ItemPath -> {
val path = property.value.path
binding.propertyValue.setText(path.resolve(context))
}
is SongProperty.Value.Size -> {
val size = property.value.sizeBytes
binding.propertyValue.setText(Formatter.formatFileSize(context, size))
}
is SongProperty.Value.Duration -> {
val duration = property.value.durationMs
binding.propertyValue.setText(duration.formatDurationMs(true))
}
is SongProperty.Value.ItemFormat -> {
val format = property.value.format
binding.propertyValue.setText(format.resolve(context))
}
is SongProperty.Value.Bitrate -> {
val kbps = property.value.kbps
binding.propertyValue.setText(context.getString(R.string.fmt_bitrate, kbps))
}
is SongProperty.Value.SampleRate -> {
val hz = property.value.hz
binding.propertyValue.setText(context.getString(R.string.fmt_sample_rate, hz))
}
is SongProperty.Value.Decibels -> {
val value = property.value.value
binding.propertyValue.setText(value.formatDb(context))
}
}
}
companion object {

View file

@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.musikr.Album
import timber.log.Timber as L
/**
* A [SortDialog] that controls the [Sort] of [DetailViewModel.albumSongSort].
@ -56,7 +56,7 @@ class AlbumSongSortDialog : SortDialog() {
private fun updateAlbum(album: Album?) {
if (album == null) {
logD("No album to sort, navigating away")
L.d("No album to sort, navigating away")
findNavController().navigateUp()
}
}

View file

@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.musikr.Artist
import timber.log.Timber as L
/**
* A [SortDialog] that controls the [Sort] of [DetailViewModel.artistSongSort].
@ -57,7 +57,7 @@ class ArtistSongSortDialog : SortDialog() {
private fun updateArtist(artist: Artist?) {
if (artist == null) {
logD("No artist to sort, navigating away")
L.d("No artist to sort, navigating away")
findNavController().navigateUp()
}
}

View file

@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.musikr.Genre
import timber.log.Timber as L
/**
* A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort].
@ -62,7 +62,7 @@ class GenreSongSortDialog : SortDialog() {
private fun updateGenre(genre: Genre?) {
if (genre == null) {
logD("No genre to sort, navigating away")
L.d("No genre to sort, navigating away")
findNavController().navigateUp()
}
}

View file

@ -26,9 +26,9 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import org.oxycblt.musikr.Playlist
import timber.log.Timber as L
/**
* A [SortDialog] that controls the [Sort] of [DetailViewModel.genreSongSort].
@ -62,7 +62,7 @@ class PlaylistSongSortDialog : SortDialog() {
private fun updatePlaylist(genre: Playlist?) {
if (genre == null) {
logD("No genre to sort, navigating away")
L.d("No genre to sort, navigating away")
findNavController().navigateUp()
}
}

View file

@ -24,9 +24,11 @@ import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.navArgs
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.openInBrowser
@ -42,10 +44,12 @@ import org.oxycblt.auxio.util.showToast
class ErrorDetailsDialog : ViewBindingMaterialDialogFragment<DialogErrorDetailsBinding>() {
private val args: ErrorDetailsDialogArgs by navArgs()
private var clipboardManager: ClipboardManager? = null
private val musicModel: MusicViewModel by viewModels()
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder
.setTitle(R.string.lbl_error_info)
.setNeutralButton(R.string.lbl_retry) { _, _ -> musicModel.refresh() }
.setPositiveButton(R.string.lbl_report) { _, _ ->
requireContext().openInBrowser(LINK_ISSUES)
}

View file

@ -22,11 +22,10 @@ import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.MenuCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
@ -38,16 +37,11 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.tabs.TabLayoutMediator
import com.google.android.material.transition.MaterialSharedAxis
import com.leinardi.android.speeddial.SpeedDialActionItem
import com.leinardi.android.speeddial.SpeedDialView
import dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Field
import java.lang.reflect.Method
import kotlin.math.abs
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeBinding
import org.oxycblt.auxio.detail.DetailViewModel
@ -57,36 +51,28 @@ import org.oxycblt.auxio.home.list.ArtistListFragment
import org.oxycblt.auxio.home.list.GenreListFragment
import org.oxycblt.auxio.home.list.PlaylistListFragment
import org.oxycblt.auxio.home.list.SongListFragment
import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy
import org.oxycblt.auxio.home.tabs.NamedTabStrategy
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectionFragment
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.IndexingProgress
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.NoAudioPermissionException
import org.oxycblt.auxio.music.NoMusicException
import org.oxycblt.auxio.music.PERMISSION_READ_AUDIO
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast
import org.oxycblt.musikr.IndexingProgress
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.playlist.m3u.M3U
import timber.log.Timber as L
/**
* The starting [SelectionFragment] of Auxio. Shows the user's music library and enables navigation
@ -96,9 +82,7 @@ import org.oxycblt.auxio.util.showToast
*/
@AndroidEntryPoint
class HomeFragment :
SelectionFragment<FragmentHomeBinding>(),
AppBarLayout.OnOffsetChangedListener,
SpeedDialView.OnActionSelectedListener {
SelectionFragment<FragmentHomeBinding>(), AppBarLayout.OnOffsetChangedListener {
override val listModel: ListViewModel by activityViewModels()
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
@ -111,15 +95,10 @@ class HomeFragment :
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState != null) {
// Orientation change will wipe whatever transition we were using prior, which will
// result in no transition when the user navigates back. Make sure we re-initialize
// our transitions.
val axis = savedInstanceState.getInt(KEY_LAST_TRANSITION_ID, -1)
if (axis > -1) {
applyAxisTransition(axis)
}
}
enterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
returnTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
exitTransition = MaterialSharedAxis(MaterialSharedAxis.Z, true)
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.Z, false)
}
override fun onCreateBinding(inflater: LayoutInflater) = FragmentHomeBinding.inflate(inflater)
@ -139,11 +118,11 @@ class HomeFragment :
getContentLauncher =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
logW("No URI returned from file picker")
L.w("No URI returned from file picker")
return@registerForActivityResult
}
logD("Received playlist URI $uri")
L.d("Received playlist URI $uri")
musicModel.importPlaylist(uri, pendingImportTarget)
}
@ -155,11 +134,6 @@ class HomeFragment :
MenuCompat.setGroupDividerEnabled(menu, true)
}
// Load the track color in manually as it's unclear whether the track actually supports
// using a ColorStateList in the resources
binding.homeIndexingProgress.trackColor =
requireContext().getColorCompat(R.color.sel_track).defaultColor
binding.homePager.apply {
// Update HomeViewModel whenever the user swipes through the ViewPager.
// This would be implemented in HomeFragment itself, but OnPageChangeCallback
@ -196,25 +170,10 @@ class HomeFragment :
// re-creating the ViewPager.
setupPager(binding)
binding.homeShuffleFab.setOnClickListener { playbackModel.shuffleAll() }
binding.homeNewPlaylistFab.apply {
inflate(R.menu.new_playlist_actions)
setOnActionSelectedListener(this@HomeFragment)
setChangeListener(homeModel::setSpeedDialOpen)
}
hideAllFabs()
updateFabVisibility(
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
// --- VIEWMODEL SETUP ---
collect(homeModel.recreateTabs.flow, ::handleRecreate)
collect(homeModel.chooseMusicLocations.flow, ::handleChooseFolders)
collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
collectImmediately(homeModel.songList, homeModel.isFastScrolling, ::updateFab)
collect(homeModel.speedDialOpen, ::updateSpeedDial)
collect(detailModel.toShow.flow, ::handleShow)
collect(listModel.menu.flow, ::handleMenu)
collectImmediately(listModel.selected, ::updateSelection)
@ -224,37 +183,11 @@ class HomeFragment :
collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision)
}
override fun onResume() {
super.onResume()
// Stock bottom sheet overlay won't work with our nested UI setup, have to replicate
// it ourselves.
requireBinding().root.rootView.apply {
findViewById<View>(R.id.main_scrim).setOnTouchListener { _, event ->
handleSpeedDialBoundaryTouch(event)
}
findViewById<View>(R.id.sheet_scrim).setOnTouchListener { _, event ->
handleSpeedDialBoundaryTouch(event)
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
val transition = enterTransition
if (transition is MaterialSharedAxis) {
outState.putInt(KEY_LAST_TRANSITION_ID, transition.axis)
}
super.onSaveInstanceState(outState)
}
override fun onDestroyBinding(binding: FragmentHomeBinding) {
super.onDestroyBinding(binding)
storagePermissionLauncher = null
binding.homeAppbar.removeOnOffsetChangedListener(this)
binding.homeNormalToolbar.setOnMenuItemClickListener(null)
binding.homeNewPlaylistFab.setChangeListener(null)
binding.homeNewPlaylistFab.setOnActionSelectedListener(null)
}
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
@ -276,18 +209,17 @@ class HomeFragment :
return when (item.itemId) {
// Handle main actions (Search, Settings, About)
R.id.action_search -> {
logD("Navigating to search")
applyAxisTransition(MaterialSharedAxis.Z)
L.d("Navigating to search")
findNavController().navigateSafe(HomeFragmentDirections.search())
true
}
R.id.action_settings -> {
logD("Navigating to preferences")
L.d("Navigating to preferences")
homeModel.showSettings()
true
}
R.id.action_about -> {
logD("Navigating to about")
L.d("Navigating to about")
homeModel.showAbout()
true
}
@ -307,30 +239,12 @@ class HomeFragment :
true
}
else -> {
logW("Unexpected menu item selected")
L.w("Unexpected menu item selected")
false
}
}
}
override fun onActionSelected(actionItem: SpeedDialActionItem): Boolean {
when (actionItem.id) {
R.id.action_new_playlist -> {
logD("Creating playlist")
musicModel.createPlaylist()
}
R.id.action_import_playlist -> {
logD("Importing playlist")
musicModel.importPlaylist()
}
else -> {}
}
// Returning false to close th speed dial results in no animation, manually close instead.
// Adapted from Material Files: https://github.com/zhanghai/MaterialFiles
requireBinding().homeNewPlaylistFab.close()
return true
}
private fun setupPager(binding: FragmentHomeBinding) {
binding.homePager.adapter =
HomePagerAdapter(homeModel.currentTabTypes, childFragmentManager, viewLifecycleOwner)
@ -339,7 +253,7 @@ class HomeFragment :
if (homeModel.currentTabTypes.size == 1) {
// A single tab makes the tab layout redundant, hide it and disable the collapsing
// behavior.
logD("Single tab shown, disabling TabLayout")
L.d("Single tab shown, disabling TabLayout")
binding.homeTabs.isVisible = false
binding.homeAppbar.setExpanded(true, false)
toolbarParams.scrollFlags = 0
@ -352,9 +266,7 @@ class HomeFragment :
// Set up the mapping between the ViewPager and TabLayout.
TabLayoutMediator(
binding.homeTabs,
binding.homePager,
AdaptiveTabStrategy(requireContext(), homeModel.currentTabTypes))
binding.homeTabs, binding.homePager, NamedTabStrategy(homeModel.currentTabTypes))
.attach()
}
@ -372,14 +284,12 @@ class HomeFragment :
MusicType.GENRES -> R.id.home_genre_recycler
MusicType.PLAYLISTS -> R.id.home_playlist_recycler
}
updateFabVisibility(homeModel.songList.value, homeModel.isFastScrolling.value, tabType)
}
private fun handleRecreate(recreate: Unit?) {
if (recreate == null) return
val binding = requireBinding()
logD("Recreating ViewPager")
L.d("Recreating ViewPager")
// Move back to position zero, as there must be a tab there.
binding.homePager.currentItem = 0
// Make sure tabs are set up to also follow the new ViewPager configuration.
@ -387,104 +297,49 @@ class HomeFragment :
homeModel.recreateTabs.consume()
}
private fun updateIndexerState(state: IndexingState?) {
// TODO: Make music loading experience a bit more pleasant
// 1. Loading placeholder for item lists
// 2. Rework the "No Music" case to not be an error and instead result in a placeholder
val binding = requireBinding()
when (state) {
is IndexingState.Completed -> setupCompleteState(binding, state.error)
is IndexingState.Indexing -> setupIndexingState(binding, state.progress)
null -> {
logD("Indexer is in indeterminate state")
binding.homeIndexingContainer.visibility = View.INVISIBLE
}
}
}
private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
if (error == null) {
logD("Received ok response")
updateFabVisibility(
homeModel.songList.value,
homeModel.isFastScrolling.value,
homeModel.currentTabType.value)
binding.homeIndexingContainer.visibility = View.INVISIBLE
private fun handleChooseFolders(unit: Unit?) {
if (unit == null) {
return
}
logD("Received non-ok response")
val context = requireContext()
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.INVISIBLE
binding.homeIndexingActions.visibility = View.VISIBLE
when (error) {
is NoAudioPermissionException -> {
logD("Showing permission prompt")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
// Configure the action to act as a permission launcher.
binding.homeIndexingTry.apply {
text = context.getString(R.string.lbl_grant)
setOnClickListener {
requireNotNull(storagePermissionLauncher) {
"Permission launcher was not available"
}
.launch(PERMISSION_READ_AUDIO)
}
}
binding.homeIndexingMore.visibility = View.GONE
}
is NoMusicException -> {
logD("Showing no music error")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
// Configure the action to act as a reload trigger.
binding.homeIndexingTry.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.refresh() }
}
binding.homeIndexingMore.visibility = View.GONE
}
else -> {
logD("Showing generic error")
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
// Configure the action to act as a reload trigger.
binding.homeIndexingTry.apply {
visibility = View.VISIBLE
text = context.getString(R.string.lbl_retry)
setOnClickListener { musicModel.rescan() }
}
binding.homeIndexingMore.apply {
visibility = View.VISIBLE
setOnClickListener {
findNavController().navigateSafe(HomeFragmentDirections.reportError(error))
}
}
}
}
findNavController().navigateSafe(HomeFragmentDirections.chooseLocations())
homeModel.chooseMusicLocations.consume()
}
private fun setupIndexingState(binding: FragmentHomeBinding, progress: IndexingProgress) {
// Remove all content except for the progress indicator.
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.VISIBLE
binding.homeIndexingActions.visibility = View.INVISIBLE
when (progress) {
is IndexingProgress.Indeterminate -> {
// In a query/initialization state, show a generic loading status.
binding.homeIndexingStatus.text = getString(R.string.lng_indexing)
binding.homeIndexingProgress.isIndeterminate = true
private fun updateIndexerState(state: IndexingState?) {
val binding = requireBinding()
when (state) {
is IndexingState.Completed -> {
binding.homeIndexingContainer.isInvisible = state.error == null
binding.homeIndexingProgress.isInvisible = state.error != null
binding.homeIndexingError.isInvisible = state.error == null
if (state.error != null) {
binding.homeIndexingContainer.setOnClickListener {
findNavController()
.navigateSafe(HomeFragmentDirections.reportError(state.error))
}
is IndexingProgress.Songs -> {
// Actively loading songs, show the current progress.
binding.homeIndexingStatus.text =
getString(R.string.fmt_indexing, progress.current, progress.total)
} else {
binding.homeIndexingContainer.setOnClickListener(null)
}
}
is IndexingState.Indexing -> {
binding.homeIndexingContainer.isInvisible = false
binding.homeIndexingProgress.apply {
isInvisible = false
when (state.progress) {
is IndexingProgress.Songs -> {
isIndeterminate = false
max = progress.total
this.progress = progress.current
progress = state.progress.loaded
max = state.progress.explored
}
is IndexingProgress.Indeterminate -> {
isIndeterminate = true
}
}
}
binding.homeIndexingError.isInvisible = true
}
null -> {
binding.homeIndexingContainer.isInvisible = true
}
}
}
@ -494,14 +349,14 @@ class HomeFragment :
val directions =
when (decision) {
is PlaylistDecision.New -> {
logD("Creating new playlist")
L.d("Creating new playlist")
HomeFragmentDirections.newPlaylist(
decision.songs.map { it.uid }.toTypedArray(),
decision.template,
decision.reason)
}
is PlaylistDecision.Import -> {
logD("Importing playlist")
L.d("Importing playlist")
pendingImportTarget = decision.target
requireNotNull(getContentLauncher) {
"Content picker launcher was not available"
@ -511,7 +366,7 @@ class HomeFragment :
return
}
is PlaylistDecision.Rename -> {
logD("Renaming ${decision.playlist}")
L.d("Renaming ${decision.playlist}")
HomeFragmentDirections.renamePlaylist(
decision.playlist.uid,
decision.template,
@ -519,15 +374,15 @@ class HomeFragment :
decision.reason)
}
is PlaylistDecision.Export -> {
logD("Exporting ${decision.playlist}")
L.d("Exporting ${decision.playlist}")
HomeFragmentDirections.exportPlaylist(decision.playlist.uid)
}
is PlaylistDecision.Delete -> {
logD("Deleting ${decision.playlist}")
L.d("Deleting ${decision.playlist}")
HomeFragmentDirections.deletePlaylist(decision.playlist.uid)
}
is PlaylistDecision.Add -> {
logD("Adding ${decision.songs.size} to a playlist")
L.d("Adding ${decision.songs.size} to a playlist")
HomeFragmentDirections.addToPlaylist(
decision.songs.map { it.uid }.toTypedArray())
}
@ -555,157 +410,41 @@ class HomeFragment :
}
}
private fun updateFab(songs: List<Song>, isFastScrolling: Boolean) {
updateFabVisibility(songs, isFastScrolling, homeModel.currentTabType.value)
}
private fun updateFabVisibility(
songs: List<Song>,
isFastScrolling: Boolean,
tabType: MusicType
) {
val binding = requireBinding()
// If there are no songs, it's likely that the library has not been loaded, so
// displaying the shuffle FAB makes no sense. We also don't want the fast scroll
// popup to overlap with the FAB, so we hide the FAB when fast scrolling too.
if (songs.isEmpty() || isFastScrolling) {
logD("Hiding fab: [empty: ${songs.isEmpty()} scrolling: $isFastScrolling]")
hideAllFabs()
} else {
if (tabType != MusicType.PLAYLISTS) {
logD("Showing shuffle button")
if (binding.homeShuffleFab.isOrWillBeShown) {
logD("Nothing to do")
return
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
logD("Animating transition")
binding.homeNewPlaylistFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
binding.homeShuffleFab.show()
}
})
} else {
logD("Showing immediately")
binding.homeShuffleFab.show()
}
} else {
logD("Showing playlist button")
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
logD("Nothing to do")
return
}
if (binding.homeShuffleFab.isOrWillBeShown) {
logD("Animating transition")
binding.homeShuffleFab.hide(
object : FloatingActionButton.OnVisibilityChangedListener() {
override fun onHidden(fab: FloatingActionButton) {
super.onHidden(fab)
binding.homeNewPlaylistFab.show()
}
})
} else {
logD("Showing immediately")
binding.homeNewPlaylistFab.show()
}
}
}
}
private fun hideAllFabs() {
val binding = requireBinding()
if (binding.homeShuffleFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeShuffleFab, null, false)
}
if (binding.homeNewPlaylistFab.mainFab.isOrWillBeShown) {
FAB_HIDE_FROM_USER_FIELD.invoke(binding.homeNewPlaylistFab.mainFab, null, false)
}
}
private fun updateSpeedDial(open: Boolean) {
val binding = requireBinding()
if (open) {
binding.homeNewPlaylistFab.open(true)
} else {
binding.homeNewPlaylistFab.close(true)
}
}
private fun handleSpeedDialBoundaryTouch(event: MotionEvent): Boolean {
val binding = binding ?: return false
if (homeModel.speedDialOpen.value && binding.homeNewPlaylistFab.isUnder(event.x, event.y)) {
// Convert absolute coordinates to relative coordinates
val offsetX = event.x - binding.homeNewPlaylistFab.x
val offsetY = event.y - binding.homeNewPlaylistFab.y
// Create a new MotionEvent with relative coordinates
val relativeEvent =
MotionEvent.obtain(
event.downTime,
event.eventTime,
event.action,
offsetX,
offsetY,
event.metaState)
// Dispatch the relative MotionEvent to the target child view
val handled = binding.homeNewPlaylistFab.dispatchTouchEvent(relativeEvent)
// Recycle the relative MotionEvent
relativeEvent.recycle()
return handled
}
return false
}
private fun handleShow(show: Show?) {
when (show) {
is Show.SongDetails -> {
logD("Navigating to ${show.song}")
L.d("Navigating to ${show.song}")
findNavController().navigateSafe(HomeFragmentDirections.showSong(show.song.uid))
}
is Show.SongAlbumDetails -> {
logD("Navigating to the album of ${show.song}")
applyAxisTransition(MaterialSharedAxis.X)
L.d("Navigating to the album of ${show.song}")
findNavController()
.navigateSafe(HomeFragmentDirections.showAlbum(show.song.album.uid))
}
is Show.AlbumDetails -> {
logD("Navigating to ${show.album}")
applyAxisTransition(MaterialSharedAxis.X)
L.d("Navigating to ${show.album}")
findNavController().navigateSafe(HomeFragmentDirections.showAlbum(show.album.uid))
}
is Show.ArtistDetails -> {
logD("Navigating to ${show.artist}")
applyAxisTransition(MaterialSharedAxis.X)
L.d("Navigating to ${show.artist}")
findNavController().navigateSafe(HomeFragmentDirections.showArtist(show.artist.uid))
}
is Show.SongArtistDecision -> {
logD("Navigating to artist choices for ${show.song}")
L.d("Navigating to artist choices for ${show.song}")
findNavController()
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.song.uid))
}
is Show.AlbumArtistDecision -> {
logD("Navigating to artist choices for ${show.album}")
L.d("Navigating to artist choices for ${show.album}")
findNavController()
.navigateSafe(HomeFragmentDirections.showArtistChoices(show.album.uid))
}
is Show.GenreDetails -> {
logD("Navigating to ${show.genre}")
applyAxisTransition(MaterialSharedAxis.X)
L.d("Navigating to ${show.genre}")
findNavController().navigateSafe(HomeFragmentDirections.showGenre(show.genre.uid))
}
is Show.PlaylistDetails -> {
logD("Navigating to ${show.playlist}")
applyAxisTransition(MaterialSharedAxis.X)
L.d("Navigating to ${show.playlist}")
findNavController()
.navigateSafe(HomeFragmentDirections.showPlaylist(show.playlist.uid))
}
@ -733,7 +472,7 @@ class HomeFragment :
binding.homeSelectionToolbar.title = getString(R.string.fmt_selected, selected.size)
if (binding.homeToolbar.setVisible(R.id.home_selection_toolbar)) {
// New selection started, show the AppBarLayout to indicate the new state.
logD("Significant selection occurred, expanding AppBar")
L.d("Significant selection occurred, expanding AppBar")
binding.homeAppbar.expandWithScrollingRecycler()
}
} else {
@ -741,18 +480,6 @@ class HomeFragment :
}
}
private fun applyAxisTransition(axis: Int) {
// Sanity check to avoid in-correct axis transitions
check(axis == MaterialSharedAxis.X || axis == MaterialSharedAxis.Z) {
"Not expecting Y axis transition"
}
enterTransition = MaterialSharedAxis(axis, true)
returnTransition = MaterialSharedAxis(axis, false)
exitTransition = MaterialSharedAxis(axis, true)
reenterTransition = MaterialSharedAxis(axis, false)
}
/**
* [FragmentStateAdapter] implementation for the [HomeFragment]'s [ViewPager2] instance.
*
@ -781,12 +508,5 @@ class HomeFragment :
private companion object {
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop")
val FAB_HIDE_FROM_USER_FIELD: Method by
lazyReflectedMethod(
FloatingActionButton::class,
"hide",
FloatingActionButton.OnVisibilityChangedListener::class,
Boolean::class)
const val KEY_LAST_TRANSITION_ID = BuildConfig.APPLICATION_ID + ".key.LAST_TRANSITION_AXIS"
}
}

View file

@ -0,0 +1,177 @@
/*
* Copyright (c) 2024 Auxio Project
* HomeGenerator.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home
import javax.inject.Inject
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
interface HomeGenerator {
fun attach()
fun release()
fun empty(): Boolean
fun songs(): List<Song>
fun albums(): List<Album>
fun artists(): List<Artist>
fun genres(): List<Genre>
fun playlists(): List<Playlist>
fun tabs(): List<MusicType>
interface Invalidator {
fun invalidateEmpty() {}
fun invalidateMusic(type: MusicType, instructions: UpdateInstructions)
fun invalidateTabs()
}
interface Factory {
fun create(invalidator: Invalidator): HomeGenerator
}
}
class HomeGeneratorFactoryImpl
@Inject
constructor(
private val homeSettings: HomeSettings,
private val listSettings: ListSettings,
private val musicRepository: MusicRepository,
) : HomeGenerator.Factory {
override fun create(invalidator: HomeGenerator.Invalidator): HomeGenerator =
HomeGeneratorImpl(invalidator, homeSettings, listSettings, musicRepository)
}
private class HomeGeneratorImpl(
private val invalidator: HomeGenerator.Invalidator,
private val homeSettings: HomeSettings,
private val listSettings: ListSettings,
private val musicRepository: MusicRepository,
) : HomeGenerator, HomeSettings.Listener, ListSettings.Listener, MusicRepository.UpdateListener {
override fun attach() {
homeSettings.registerListener(this)
listSettings.registerListener(this)
musicRepository.addUpdateListener(this)
}
override fun onTabsChanged() {
invalidator.invalidateTabs()
}
override fun onHideCollaboratorsChanged() {
// Changes in the hide collaborator setting will change the artist contents
// of the library, consider it a library update.
L.d("Collaborator setting changed, forwarding update")
invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Diff)
}
override fun onSongSortChanged() {
super.onSongSortChanged()
invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Replace(0))
}
override fun onAlbumSortChanged() {
super.onAlbumSortChanged()
invalidator.invalidateMusic(MusicType.ALBUMS, UpdateInstructions.Replace(0))
}
override fun onArtistSortChanged() {
super.onArtistSortChanged()
invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Replace(0))
}
override fun onGenreSortChanged() {
super.onGenreSortChanged()
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Replace(0))
}
override fun onPlaylistSortChanged() {
super.onPlaylistSortChanged()
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Replace(0))
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
invalidator.invalidateEmpty()
val library = musicRepository.library
if (changes.deviceLibrary && library != null) {
L.d("Refreshing library")
// Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them.
invalidator.invalidateMusic(MusicType.SONGS, UpdateInstructions.Diff)
invalidator.invalidateMusic(MusicType.ALBUMS, UpdateInstructions.Diff)
invalidator.invalidateMusic(MusicType.ARTISTS, UpdateInstructions.Diff)
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff)
}
if (changes.userLibrary && library != null) {
L.d("Refreshing playlists")
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff)
}
}
override fun release() {
musicRepository.removeUpdateListener(this)
listSettings.unregisterListener(this)
homeSettings.unregisterListener(this)
}
override fun empty() = musicRepository.library?.empty() ?: true
override fun songs() =
musicRepository.library?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
override fun albums() =
musicRepository.library?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList()
override fun artists() =
musicRepository.library?.let { deviceLibrary ->
val sorted = listSettings.artistSort.artists(deviceLibrary.artists)
if (homeSettings.shouldHideCollaborators) {
sorted.filter { it.explicitAlbums.isNotEmpty() }
} else {
sorted
}
} ?: emptyList()
override fun genres() =
musicRepository.library?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList()
override fun playlists() =
musicRepository.library?.let { listSettings.playlistSort.playlists(it.playlists) }
?: emptyList()
override fun tabs() = homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }
}

View file

@ -27,4 +27,6 @@ import dagger.hilt.components.SingletonComponent
@InstallIn(SingletonComponent::class)
interface HomeModule {
@Binds fun settings(homeSettings: HomeSettingsImpl): HomeSettings
@Binds fun homeGeneratorFactory(factory: HomeGeneratorFactoryImpl): HomeGenerator.Factory
}

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
import timber.log.Timber as L
/**
* User configuration specific to the home UI.
@ -42,9 +42,9 @@ interface HomeSettings : Settings<HomeSettings.Listener> {
interface Listener {
/** Called when the [homeTabs] configuration changes. */
fun onTabsChanged()
fun onTabsChanged() {}
/** Called when the [shouldHideCollaborators] configuration changes. */
fun onHideCollaboratorsChanged()
fun onHideCollaboratorsChanged() {}
}
}
@ -68,17 +68,17 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
override fun migrate() {
if (sharedPreferences.contains(OLD_KEY_LIB_TABS)) {
logD("Migrating tab setting")
L.d("Migrating tab setting")
val oldTabs =
Tab.fromIntCode(sharedPreferences.getInt(OLD_KEY_LIB_TABS, Tab.SEQUENCE_DEFAULT))
?: unlikelyToBeNull(Tab.fromIntCode(Tab.SEQUENCE_DEFAULT))
logD("Old tabs: $oldTabs")
L.d("Old tabs: $oldTabs")
// The playlist tab is now parsed, but it needs to be made visible.
val playlistIndex = oldTabs.indexOfFirst { it.type == MusicType.PLAYLISTS }
check(playlistIndex > -1) // This should exist, otherwise we are in big trouble
oldTabs[playlistIndex] = Tab.Visible(MusicType.PLAYLISTS)
logD("New tabs: $oldTabs")
L.d("New tabs: $oldTabs")
sharedPreferences.edit {
putInt(getString(R.string.set_key_home_tabs), Tab.toIntCode(oldTabs))
@ -90,11 +90,11 @@ class HomeSettingsImpl @Inject constructor(@ApplicationContext context: Context)
override fun onSettingChanged(key: String, listener: HomeSettings.Listener) {
when (key) {
getString(R.string.set_key_home_tabs) -> {
logD("Dispatching tab setting change")
L.d("Dispatching tab setting change")
listener.onTabsChanged()
}
getString(R.string.set_key_hide_collaborators) -> {
logD("Dispatching collaborator setting change")
L.d("Dispatching collaborator setting change")
listener.onHideCollaboratorsChanged()
}
}

View file

@ -27,18 +27,17 @@ import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.logD
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* The ViewModel for managing the tab data and lists of the home view.
@ -49,12 +48,10 @@ import org.oxycblt.auxio.util.logD
class HomeViewModel
@Inject
constructor(
private val homeSettings: HomeSettings,
private val listSettings: ListSettings,
private val playbackSettings: PlaybackSettings,
private val musicRepository: MusicRepository,
) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener {
homeGeneratorFactory: HomeGenerator.Factory
) : ViewModel(), HomeGenerator.Invalidator {
private val _songList = MutableStateFlow(listOf<Song>())
/** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */
val songList: StateFlow<List<Song>>
@ -123,6 +120,10 @@ constructor(
val playlistList: StateFlow<List<Playlist>>
get() = _playlistList
private val _empty = MutableStateFlow(false)
val empty: StateFlow<Boolean>
get() = _empty
private val _playlistInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [genreList] in the UI. */
val playlistInstructions: Event<UpdateInstructions>
@ -132,11 +133,13 @@ constructor(
val playlistSort: Sort
get() = listSettings.playlistSort
private val homeGenerator = homeGeneratorFactory.create(this)
/**
* A list of [MusicType] corresponding to the current [Tab] configuration, excluding invisible
* [Tab]s.
*/
var currentTabTypes = makeTabTypes()
var currentTabTypes = homeGenerator.tabs()
private set
private val _currentTabType = MutableStateFlow(currentTabTypes[0])
@ -156,72 +159,57 @@ constructor(
/** A marker for whether the user is fast-scrolling in the home view or not. */
val isFastScrolling: StateFlow<Boolean> = _isFastScrolling
private val _speedDialOpen = MutableStateFlow(false)
/** A marker for whether the speed dial is open or not. */
val speedDialOpen: StateFlow<Boolean> = _speedDialOpen
private val _showOuter = MutableEvent<Outer>()
val showOuter: Event<Outer>
get() = _showOuter
private val _chooseMusicLocations = MutableEvent<Unit>()
val chooseMusicLocations: Event<Unit>
get() = _chooseMusicLocations
init {
musicRepository.addUpdateListener(this)
homeSettings.registerListener(this)
homeGenerator.attach()
}
override fun onCleared() {
super.onCleared()
musicRepository.removeUpdateListener(this)
homeSettings.unregisterListener(this)
homeGenerator.release()
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary
if (changes.deviceLibrary && deviceLibrary != null) {
logD("Refreshing library")
// Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them.
_songInstructions.put(UpdateInstructions.Diff)
_songList.value = listSettings.songSort.songs(deviceLibrary.songs)
_albumInstructions.put(UpdateInstructions.Diff)
_albumList.value = listSettings.albumSort.albums(deviceLibrary.albums)
_artistInstructions.put(UpdateInstructions.Diff)
_artistList.value =
listSettings.artistSort.artists(
if (homeSettings.shouldHideCollaborators) {
logD("Filtering collaborator artists")
// Hide Collaborators is enabled, filter out collaborators.
deviceLibrary.artists.filter { it.explicitAlbums.isNotEmpty() }
} else {
logD("Using all artists")
deviceLibrary.artists
})
_genreInstructions.put(UpdateInstructions.Diff)
_genreList.value = listSettings.genreSort.genres(deviceLibrary.genres)
override fun invalidateEmpty() {
_empty.value = homeGenerator.empty()
}
val userLibrary = musicRepository.userLibrary
if (changes.userLibrary && userLibrary != null) {
logD("Refreshing playlists")
_playlistInstructions.put(UpdateInstructions.Diff)
_playlistList.value = listSettings.playlistSort.playlists(userLibrary.playlists)
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
when (type) {
MusicType.SONGS -> {
_songInstructions.put(instructions)
_songList.value = homeGenerator.songs()
}
MusicType.ALBUMS -> {
_albumInstructions.put(instructions)
_albumList.value = homeGenerator.albums()
}
MusicType.ARTISTS -> {
_artistInstructions.put(instructions)
_artistList.value = homeGenerator.artists()
}
MusicType.GENRES -> {
_genreInstructions.put(instructions)
_genreList.value = homeGenerator.genres()
}
MusicType.PLAYLISTS -> {
_playlistInstructions.put(instructions)
_playlistList.value = homeGenerator.playlists()
}
}
}
override fun onTabsChanged() {
// Tabs changed, update the current tabs and set up a re-create event.
currentTabTypes = makeTabTypes()
logD("Updating tabs: ${currentTabType.value}")
override fun invalidateTabs() {
currentTabTypes = homeGenerator.tabs()
_shouldRecreate.put(Unit)
}
override fun onHideCollaboratorsChanged() {
// Changes in the hide collaborator setting will change the artist contents
// of the library, consider it a library update.
logD("Collaborator setting changed, forwarding update")
onMusicChanges(MusicRepository.Changes(deviceLibrary = true, userLibrary = false))
}
/**
* Apply a new [Sort] to [songList].
*
@ -229,8 +217,6 @@ constructor(
*/
fun applySongSort(sort: Sort) {
listSettings.songSort = sort
_songInstructions.put(UpdateInstructions.Replace(0))
_songList.value = listSettings.songSort.songs(_songList.value)
}
/**
@ -240,8 +226,6 @@ constructor(
*/
fun applyAlbumSort(sort: Sort) {
listSettings.albumSort = sort
_albumInstructions.put(UpdateInstructions.Replace(0))
_albumList.value = listSettings.albumSort.albums(_albumList.value)
}
/**
@ -251,8 +235,6 @@ constructor(
*/
fun applyArtistSort(sort: Sort) {
listSettings.artistSort = sort
_artistInstructions.put(UpdateInstructions.Replace(0))
_artistList.value = listSettings.artistSort.artists(_artistList.value)
}
/**
@ -262,8 +244,6 @@ constructor(
*/
fun applyGenreSort(sort: Sort) {
listSettings.genreSort = sort
_genreInstructions.put(UpdateInstructions.Replace(0))
_genreList.value = listSettings.genreSort.genres(_genreList.value)
}
/**
@ -273,8 +253,6 @@ constructor(
*/
fun applyPlaylistSort(sort: Sort) {
listSettings.playlistSort = sort
_playlistInstructions.put(UpdateInstructions.Replace(0))
_playlistList.value = listSettings.playlistSort.playlists(_playlistList.value)
}
/**
@ -283,7 +261,7 @@ constructor(
* @param pagerPos The new position of the ViewPager2 instance.
*/
fun synchronizeTabPosition(pagerPos: Int) {
logD("Updating current tab to ${currentTabTypes[pagerPos]}")
L.d("Updating current tab to ${currentTabTypes[pagerPos]}")
_currentTabType.value = currentTabTypes[pagerPos]
}
@ -293,18 +271,12 @@ constructor(
* @param isFastScrolling true if the user is currently fast scrolling, false otherwise.
*/
fun setFastScrolling(isFastScrolling: Boolean) {
logD("Updating fast scrolling state: $isFastScrolling")
L.d("Updating fast scrolling state: $isFastScrolling")
_isFastScrolling.value = isFastScrolling
}
/**
* Update whether the speed dial is open or not.
*
* @param speedDialOpen true if the speed dial is open, false otherwise.
*/
fun setSpeedDialOpen(speedDialOpen: Boolean) {
logD("Updating speed dial state: $speedDialOpen")
_speedDialOpen.value = speedDialOpen
fun startChooseMusicLocations() {
_chooseMusicLocations.put(Unit)
}
fun showSettings() {
@ -314,15 +286,6 @@ constructor(
fun showAbout() {
_showOuter.put(Outer.About)
}
/**
* Create a list of [MusicType]s representing a simpler version of the [Tab] configuration.
*
* @return A list of the [MusicType]s for each visible [Tab] in the configuration, ordered in
* the same way as the configuration.
*/
private fun makeTabTypes() =
homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }
}
sealed interface Outer {

View file

@ -39,7 +39,6 @@ import androidx.core.os.BundleCompat
import androidx.core.view.setMargins
import androidx.core.view.updateLayoutParams
import androidx.core.widget.TextViewCompat
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.shape.MaterialShapeDrawable
import com.leinardi.android.speeddial.FabWithLabelView
import com.leinardi.android.speeddial.SpeedDialActionItem
@ -47,6 +46,7 @@ import com.leinardi.android.speeddial.SpeedDialView
import kotlin.math.roundToInt
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.AnimConfig
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getDimenPixels
@ -78,6 +78,8 @@ class ThemedSpeedDialView : SpeedDialView {
@AttrRes defStyleAttr: Int
) : super(context, attrs, defStyleAttr)
private val stationaryConfig = AnimConfig.of(context, AnimConfig.STANDARD, AnimConfig.MEDIUM2)
init {
// Work around ripple bug on Android 12 when useCompatPadding = true.
// @see https://github.com/material-components/material-components-android/issues/2617
@ -107,7 +109,7 @@ class ThemedSpeedDialView : SpeedDialView {
val mainFabDrawable =
RotateDrawable().apply {
drawable = mainFab.drawable
toDegrees = mainFabAnimationRotateAngle
toDegrees = 45f + 90f
}
mainFabAnimationRotateAngle = 0f
setMainFabClosedDrawable(mainFabDrawable)
@ -116,6 +118,13 @@ class ThemedSpeedDialView : SpeedDialView {
override fun onMainActionSelected(): Boolean = false
override fun onToggleChanged(isOpen: Boolean) {
mainFab.backgroundTintList =
ColorStateList.valueOf(
if (isOpen) mainFabClosedBackgroundColor
else mainFabOpenedBackgroundColor)
mainFab.imageTintList =
ColorStateList.valueOf(
if (isOpen) mainFabClosedIconColor else mainFabOpenedIconColor)
mainFabAnimator?.cancel()
mainFabAnimator =
createMainFabAnimator(isOpen).apply {
@ -132,21 +141,43 @@ class ThemedSpeedDialView : SpeedDialView {
})
}
private fun createMainFabAnimator(isOpen: Boolean): Animator =
AnimatorSet().apply {
playTogether(
private fun createMainFabAnimator(isOpen: Boolean): Animator {
val totalDuration = stationaryConfig.duration
val partialDuration = totalDuration / 2 // This is half of the total duration
val delay = totalDuration / 4 // This is one fourth of the total duration
val backgroundTintAnimator =
ObjectAnimator.ofArgb(
mainFab,
VIEW_PROPERTY_BACKGROUND_TINT,
if (isOpen) mainFabOpenedBackgroundColor else mainFabClosedBackgroundColor),
if (isOpen) mainFabOpenedBackgroundColor else mainFabClosedBackgroundColor)
.apply {
startDelay = delay
duration = partialDuration
}
val imageTintAnimator =
ObjectAnimator.ofArgb(
mainFab,
IMAGE_VIEW_PROPERTY_IMAGE_TINT,
if (isOpen) mainFabOpenedIconColor else mainFabClosedIconColor),
if (isOpen) mainFabOpenedIconColor else mainFabClosedIconColor)
.apply {
startDelay = delay
duration = partialDuration
}
val levelAnimator =
ObjectAnimator.ofInt(
mainFab.drawable, DRAWABLE_PROPERTY_LEVEL, if (isOpen) 10000 else 0))
duration = 200
interpolator = FastOutSlowInInterpolator()
mainFab.drawable, DRAWABLE_PROPERTY_LEVEL, if (isOpen) 10000 else 0)
.apply { duration = totalDuration }
val animatorSet =
AnimatorSet().apply {
playTogether(backgroundTintAnimator, imageTintAnimator, levelAnimator)
interpolator = stationaryConfig.interpolator
}
animatorSet.start()
return animatorSet
}
override fun onAttachedToWindow() {
@ -159,6 +190,8 @@ class ThemedSpeedDialView : SpeedDialView {
val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f)
overlayLayout.setBackgroundColor(overlayColor)
}
// Fix default margins added by library
(mainFab.layoutParams as LayoutParams).setMargins(0, 0, 0, 0)
}
private fun Int.withModulatedAlpha(
@ -199,13 +232,24 @@ class ThemedSpeedDialView : SpeedDialView {
return super.addActionItem(actionItem, position, animate)?.apply {
fab.apply {
updateLayoutParams<MarginLayoutParams> {
val horizontalMargin = context.getDimenPixels(R.dimen.spacing_mid_large)
setMargins(horizontalMargin, 0, horizontalMargin, 0)
val rightMargin = context.getDimenPixels(R.dimen.spacing_tiny)
if (position == actionItems.lastIndex) {
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
setMargins(0, 0, rightMargin, bottomMargin)
} else {
setMargins(0, 0, rightMargin, 0)
}
}
useCompatPadding = false
}
labelBackground.apply {
updateLayoutParams<MarginLayoutParams> {
if (position == actionItems.lastIndex) {
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
setMargins(0, 0, rightMargin, bottomMargin)
}
}
useCompatPadding = false
setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall)
background =
@ -262,7 +306,7 @@ class ThemedSpeedDialView : SpeedDialView {
private val DRAWABLE_PROPERTY_LEVEL =
object : Property<Drawable, Int>(Int::class.java, "level") {
override fun get(drawable: Drawable): Int? = drawable.level
override fun get(drawable: Drawable): Int = drawable.level
override fun set(drawable: Drawable, value: Int?) {
drawable.level = value!!

View file

@ -1,185 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* FastScrollPopupView.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.fastscroll
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Matrix
import android.graphics.Outline
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.os.Build
import android.text.TextUtils
import android.util.AttributeSet
import android.view.Gravity
import androidx.core.widget.TextViewCompat
import com.google.android.material.R as MR
import com.google.android.material.textview.MaterialTextView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.isRtl
/**
* A [MaterialTextView] that displays the popup indicator used in FastScrollRecyclerView
*
* @author Alexander Capehart (OxygenCobalt), Hai Zhang
*/
class FastScrollPopupView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, defStyleRes: Int = 0) :
MaterialTextView(context, attrs, defStyleRes) {
init {
minimumWidth = context.getDimenPixels(R.dimen.fast_scroll_popup_min_width)
minimumHeight = context.getDimenPixels(R.dimen.fast_scroll_popup_min_height)
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineLarge)
setTextColor(context.getAttrColorCompat(MR.attr.colorOnSecondary))
ellipsize = TextUtils.TruncateAt.MIDDLE
gravity = Gravity.CENTER
includeFontPadding = false
alpha = 0f
elevation = context.getDimenPixels(R.dimen.elevation_normal).toFloat()
background = FastScrollPopupDrawable(context)
}
private class FastScrollPopupDrawable(context: Context) : Drawable() {
private val paint: Paint =
Paint().apply {
isAntiAlias = true
color =
context
.getAttrColorCompat(com.google.android.material.R.attr.colorSecondary)
.defaultColor
style = Paint.Style.FILL
}
private val path = Path()
private val matrix = Matrix()
private val paddingStart = context.getDimenPixels(R.dimen.fast_scroll_popup_padding_start)
private val paddingEnd = context.getDimenPixels(R.dimen.fast_scroll_popup_padding_end)
override fun draw(canvas: Canvas) {
canvas.drawPath(path, paint)
}
override fun onBoundsChange(bounds: Rect) {
updatePath()
}
override fun onLayoutDirectionChanged(layoutDirection: Int): Boolean {
updatePath()
return true
}
@Suppress("DEPRECATION")
override fun getOutline(outline: Outline) {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> outline.setPath(path)
// Paths don't need to be convex on android Q, but the API was mislabeled and so
// we still have to use this method.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> outline.setConvexPath(path)
else ->
if (!path.isConvex) {
// The outline path must be convex before Q, but we may run into floating
// point errors caused by calculations involving sqrt(2) or OEM differences,
// so in this case we just omit the shadow instead of crashing.
super.getOutline(outline)
}
}
}
override fun getPadding(padding: Rect): Boolean {
if (isRtl) {
padding[paddingEnd, 0, paddingStart] = 0
} else {
padding[paddingStart, 0, paddingEnd] = 0
}
return true
}
override fun isAutoMirrored(): Boolean = true
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: ColorFilter?) {}
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
private fun updatePath() {
val r = bounds.height().toFloat() / 2
val w = (r + SQRT2 * r).coerceAtLeast(bounds.width().toFloat())
path.apply {
reset()
// Draw the left pill shape
val o1X = w - SQRT2 * r
arcToSafe(r, r, r, 90f, 180f)
arcToSafe(o1X, r, r, -90f, 45f)
// Draw the right arrow shape
val point = r / 5
val o2X = w - SQRT2 * point
arcToSafe(o2X, r, point, -45f, 90f)
arcToSafe(o1X, r, r, 45f, 45f)
close()
}
matrix.apply {
reset()
if (isRtl) setScale(-1f, 1f, w / 2, 0f)
postTranslate(bounds.left.toFloat(), bounds.top.toFloat())
}
path.transform(matrix)
}
private fun Path.arcToSafe(
centerX: Float,
centerY: Float,
radius: Float,
startAngle: Float,
sweepAngle: Float
) {
arcTo(
centerX - radius,
centerY - radius,
centerX + radius,
centerY + radius,
startAngle,
sweepAngle,
false)
}
}
private companion object {
// Pre-calculate sqrt(2)
const val SQRT2 = 1.4142135f
}
}

View file

@ -22,6 +22,8 @@ import android.os.Bundle
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter
@ -29,22 +31,23 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Album]s.
@ -79,7 +82,16 @@ class AlbumListFragment :
listener = this@AlbumListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_album_48)
contentDescription = getString(R.string.lbl_albums)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_albums)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.albumList, ::updateAlbums)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -99,10 +111,10 @@ class AlbumListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.albumSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> album.name.thumb
is Sort.Mode.ByName -> album.name.thumb()
// By Artist -> Use name of first artist
is Sort.Mode.ByArtist -> album.artists[0].name.thumb
is Sort.Mode.ByArtist -> album.artists[0].name.thumb()
// Date -> Use minimum date (Maximum dates are not sorted by, so showing them is odd)
is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) }
@ -115,7 +127,7 @@ class AlbumListFragment :
// Last added -> Format as date
is Sort.Mode.ByDateAdded -> {
val dateAddedMillis = album.dateAdded.secsToMs()
val dateAddedMillis = album.addedMs
formatterSb.setLength(0)
DateUtils.formatDateRange(
context,
@ -147,6 +159,14 @@ class AlbumListFragment :
albumAdapter.update(albums, homeModel.albumInstructions.consume())
}
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) {
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -21,28 +21,31 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.positiveOrNull
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Artist]s.
@ -74,7 +77,16 @@ class ArtistListFragment :
listener = this@ArtistListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_artist_48)
contentDescription = getString(R.string.lbl_artists)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_artists)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.artistList, ::updateArtists)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -94,7 +106,7 @@ class ArtistListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.artistSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> artist.name.thumb
is Sort.Mode.ByName -> artist.name.thumb()
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
@ -123,6 +135,14 @@ class ArtistListFragment :
artistAdapter.update(artists, homeModel.artistInstructions.consume())
}
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) {
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -21,27 +21,30 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.GenreViewHolder
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Genre]s.
@ -73,7 +76,16 @@ class GenreListFragment :
listener = this@GenreListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_genre_48)
contentDescription = getString(R.string.lbl_genres)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_genres)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.genreList, ::updateGenres)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -93,7 +105,7 @@ class GenreListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.genreSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> genre.name.thumb
is Sort.Mode.ByName -> genre.name.thumb()
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
@ -122,6 +134,14 @@ class GenreListFragment :
genreAdapter.update(genres, homeModel.genreInstructions.consume())
}
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) {
genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2024 Auxio Project
* ListUtil.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.list
import androidx.core.text.isDigitsOnly
import org.oxycblt.musikr.tag.Name
fun Name.thumb() =
when (this) {
is Name.Known ->
tokens.firstOrNull()?.let {
if (it.value.isDigitsOnly()) "#" else it.value.first().uppercase()
}
is Name.Unknown -> "?"
}

View file

@ -21,26 +21,29 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.PlaylistViewHolder
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Playlist]s.
@ -71,7 +74,18 @@ class PlaylistListFragment :
listener = this@PlaylistListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_playlist_48)
contentDescription = getString(R.string.lbl_playlists)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_playlists)
collectImmediately(homeModel.playlistList, ::updatePlaylists)
collectImmediately(
homeModel.empty,
homeModel.playlistList,
musicModel.indexingState,
::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -91,7 +105,7 @@ class PlaylistListFragment :
// Change how we display the popup depending on the current sort mode.
return when (homeModel.playlistSort.mode) {
// By Name -> Use Name
is Sort.Mode.ByName -> playlist.name.thumb
is Sort.Mode.ByName -> playlist.name.thumb()
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)
@ -120,6 +134,26 @@ class PlaylistListFragment :
playlistAdapter.update(playlists, homeModel.playlistInstructions.consume())
}
private fun updateNoMusicIndicator(
empty: Boolean,
playlists: List<Playlist>,
indexingState: IndexingState?
) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty && playlists.isNotEmpty()
if (!empty && playlists.isEmpty()) {
binding.homeNoMusicAction.isVisible = true
binding.homeNoMusicAction.text = getString(R.string.lbl_new_playlist)
binding.homeNoMusicAction.setOnClickListener { musicModel.createPlaylist() }
} else {
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
binding.homeNoMusicAction.text = getString(R.string.lbl_music_sources)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
}
}
private fun updateSelection(selection: List<Music>) {
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -22,27 +22,30 @@ import android.os.Bundle
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView
import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Song
/**
* A [ListFragment] that shows a list of [Song]s.
@ -59,6 +62,7 @@ class SongListFragment :
override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels()
private val songAdapter = SongAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text
private val formatterSb = StringBuilder(64)
private val formatter = Formatter(formatterSb)
@ -76,7 +80,16 @@ class SongListFragment :
listener = this@SongListFragment
}
binding.homeNoMusicPlaceholder.apply {
setImageResource(R.drawable.ic_song_48)
contentDescription = getString(R.string.lbl_songs)
}
binding.homeNoMusicMsg.text = getString(R.string.lng_empty_songs)
binding.homeNoMusicAction.setOnClickListener { homeModel.startChooseMusicLocations() }
collectImmediately(homeModel.songList, ::updateSongs)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection)
collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback)
@ -98,23 +111,23 @@ class SongListFragment :
// based off the names of the parent objects and not the child objects.
return when (homeModel.songSort.mode) {
// Name -> Use name
is Sort.Mode.ByName -> song.name.thumb
is Sort.Mode.ByName -> song.name.thumb()
// Artist -> Use name of first artist
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb
is Sort.Mode.ByArtist -> song.album.artists[0].name.thumb()
// Album -> Use Album Name
is Sort.Mode.ByAlbum -> song.album.name.thumb
is Sort.Mode.ByAlbum -> song.album.name.thumb()
// Year -> Use Full Year
is Sort.Mode.ByDate -> song.album.dates?.resolveDate(requireContext())
is Sort.Mode.ByDate -> song.album.dates?.resolve(requireContext())
// Duration -> Use formatted duration
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
// Last added -> Format as date
is Sort.Mode.ByDateAdded -> {
val dateAddedMillis = song.dateAdded.secsToMs()
val dateAddedMillis = song.addedMs
formatterSb.setLength(0)
DateUtils.formatDateRange(
context,
@ -146,6 +159,14 @@ class SongListFragment :
songAdapter.update(songs, homeModel.songInstructions.consume())
}
private fun updateNoMusicIndicator(empty: Boolean, indexingState: IndexingState?) {
val binding = requireBinding()
binding.homeRecycler.isInvisible = empty
binding.homeNoMusic.isInvisible = !empty
binding.homeNoMusicAction.isVisible =
indexingState == null || (empty && indexingState is IndexingState.Completed)
}
private fun updateSelection(selection: List<Music>) {
songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
}

View file

@ -1,76 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* AdaptiveTabStrategy.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.tabs
import android.content.Context
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.MusicType
/**
* A [TabLayoutMediator.TabConfigurationStrategy] that uses larger/smaller tab configurations
* depending on the screen configuration.
*
* @param context [Context] required to obtain window information
* @param tabs Current tab configuration from settings
* @author Alexander Capehart (OxygenCobalt)
*/
class AdaptiveTabStrategy(context: Context, private val tabs: List<MusicType>) :
TabLayoutMediator.TabConfigurationStrategy {
private val width = context.resources.configuration.smallestScreenWidthDp
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val icon: Int
val string: Int
when (tabs[position]) {
MusicType.SONGS -> {
icon = R.drawable.ic_song_24
string = R.string.lbl_songs
}
MusicType.ALBUMS -> {
icon = R.drawable.ic_album_24
string = R.string.lbl_albums
}
MusicType.ARTISTS -> {
icon = R.drawable.ic_artist_24
string = R.string.lbl_artists
}
MusicType.GENRES -> {
icon = R.drawable.ic_genre_24
string = R.string.lbl_genres
}
MusicType.PLAYLISTS -> {
icon = R.drawable.ic_playlist_24
string = R.string.lbl_playlists
}
}
// Use expected sw* size thresholds when choosing a configuration.
when {
// On small screens, only display an icon.
width < 370 -> tab.setIcon(icon).setContentDescription(string)
// On large screens, display an icon and text.
width < 600 -> tab.setText(string)
// On medium-size screens, display text.
else -> tab.setIcon(icon).setText(string)
}
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Auxio Project
* NamedTabStrategy.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.tabs
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
import org.oxycblt.auxio.music.MusicType
class NamedTabStrategy(private val homeTabs: List<MusicType>) : TabConfigurationStrategy {
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
tab.setText(homeTabs[position].nameRes)
}
}

View file

@ -19,8 +19,7 @@
package org.oxycblt.auxio.home.tabs
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
import timber.log.Timber as L
/**
* A representation of a library tab suitable for configuration.
@ -86,7 +85,7 @@ sealed class Tab(open val type: MusicType) {
// Like when deserializing, make sure there are no duplicate tabs for whatever reason.
val distinct = tabs.distinctBy { it.type }
if (tabs.size != distinct.size) {
logW(
L.w(
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
}
@ -133,13 +132,13 @@ sealed class Tab(open val type: MusicType) {
// Make sure there are no duplicate tabs
val distinct = tabs.distinctBy { it.type }
if (tabs.size != distinct.size) {
logW(
L.w(
"Tab sequences should not have duplicates [old: ${tabs.size} new: ${distinct.size}]")
}
// For safety, return null if we have an empty or larger-than-expected tab array.
if (distinct.isEmpty() || distinct.size < MAX_SEQUENCE_IDX) {
logE("Sequence size was ${distinct.size}, which is invalid")
L.e("Sequence size was ${distinct.size}, which is invalid")
return null
}

View file

@ -28,7 +28,7 @@ import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.logD
import timber.log.Timber as L
/**
* A [RecyclerView.Adapter] that displays an array of [Tab]s open for configuration.
@ -55,7 +55,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
* @param newTabs The new array of tabs to show.
*/
fun submitTabs(newTabs: Array<Tab>) {
logD("Force-updating tab information")
L.d("Force-updating tab information")
tabs = newTabs
@Suppress("NotifyDatasetChanged") notifyDataSetChanged()
}
@ -67,7 +67,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
* @param tab The new tab.
*/
fun setTab(at: Int, tab: Tab) {
logD("Updating tab [at: $at, tab: $tab]")
L.d("Updating tab [at: $at, tab: $tab]")
tabs[at] = tab
// Use a payload to avoid an item change animation.
notifyItemChanged(at, PAYLOAD_TAB_CHANGED)
@ -80,7 +80,7 @@ class TabAdapter(private val listener: EditClickListListener<Tab>) :
* @param b The position of the second tab to swap.
*/
fun swapTabs(a: Int, b: Int) {
logD("Swapping tabs [a: $a, b: $b]")
L.d("Swapping tabs [a: $a, b: $b]")
val tmp = tabs[b]
tabs[b] = tabs[a]
tabs[a] = tmp

View file

@ -31,7 +31,7 @@ import org.oxycblt.auxio.databinding.DialogTabsBinding
import org.oxycblt.auxio.home.HomeSettings
import org.oxycblt.auxio.list.EditClickListListener
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.logD
import timber.log.Timber as L
/**
* A [ViewBindingMaterialDialogFragment] that allows the user to modify the home [Tab]
@ -52,7 +52,7 @@ class TabCustomizeDialog :
builder
.setTitle(R.string.set_lib_tabs)
.setPositiveButton(R.string.lbl_ok) { _, _ ->
logD("Committing tab changes")
L.d("Committing tab changes")
homeSettings.homeTabs = tabAdapter.tabs
}
.setNegativeButton(R.string.lbl_cancel, null)
@ -99,7 +99,7 @@ class TabCustomizeDialog :
is Tab.Visible -> Tab.Invisible(old.type)
is Tab.Invisible -> Tab.Visible(old.type)
}
logD("Flipping tab visibility [from: $old to: $new]")
L.d("Flipping tab visibility [from: $old to: $new]")
tabAdapter.setTab(index, new)
// Prevent the user from saving if all the tabs are Invisible, as that's an invalid state.

View file

@ -20,14 +20,14 @@ package org.oxycblt.auxio.image
import android.content.Context
import android.graphics.Bitmap
import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.request.Disposable
import coil.request.ImageRequest
import coil.size.Size
import coil3.ImageLoader
import coil3.request.Disposable
import coil3.request.ImageRequest
import coil3.size.Size
import coil3.toBitmap
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.music.Song
import org.oxycblt.musikr.Song
/**
* A utility to provide bitmaps in a race-less manner.
@ -94,7 +94,7 @@ constructor(
target
.onConfigRequest(
ImageRequest.Builder(context)
.data(listOf(song.cover))
.data(song.cover)
// Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL))
.target(

View file

@ -26,12 +26,11 @@ import org.oxycblt.auxio.IntegerTable
* @author Alexander Capehart (OxygenCobalt)
*/
enum class CoverMode {
/** Do not load album covers ("Off"). */
OFF,
/** Load covers from the fast, but lower-quality media store database ("Fast"). */
MEDIA_STORE,
/** Load high-quality covers directly from music files ("Quality"). */
QUALITY;
SAVE_SPACE,
BALANCED,
HIGH_QUALITY,
AS_IS;
/**
* The integer representation of this instance.
@ -42,8 +41,10 @@ enum class CoverMode {
get() =
when (this) {
OFF -> IntegerTable.COVER_MODE_OFF
MEDIA_STORE -> IntegerTable.COVER_MODE_MEDIA_STORE
QUALITY -> IntegerTable.COVER_MODE_QUALITY
SAVE_SPACE -> IntegerTable.COVER_MODE_SAVE_SPACE
BALANCED -> IntegerTable.COVER_MODE_BALANCED
HIGH_QUALITY -> IntegerTable.COVER_MODE_HIGH_QUALITY
AS_IS -> IntegerTable.COVER_MODE_AS_IS
}
companion object {
@ -57,8 +58,10 @@ enum class CoverMode {
fun fromIntCode(intCode: Int) =
when (intCode) {
IntegerTable.COVER_MODE_OFF -> OFF
IntegerTable.COVER_MODE_MEDIA_STORE -> MEDIA_STORE
IntegerTable.COVER_MODE_QUALITY -> QUALITY
IntegerTable.COVER_MODE_SAVE_SPACE -> SAVE_SPACE
IntegerTable.COVER_MODE_BALANCED -> BALANCED
IntegerTable.COVER_MODE_HIGH_QUALITY -> HIGH_QUALITY
IntegerTable.COVER_MODE_AS_IS -> AS_IS
else -> null
}
}

View file

@ -0,0 +1,86 @@
/*
* Copyright (c) 2025 Auxio Project
* CoverProvider.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image
import android.content.ContentProvider
import android.content.ContentResolver
import android.content.ContentValues
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.runBlocking
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.image.covers.SettingCovers
import org.oxycblt.musikr.covers.CoverResult
class CoverProvider : ContentProvider() {
override fun onCreate(): Boolean = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
if (mode != "r" || uriMatcher.match(uri) != 1) {
return null
}
val id = uri.lastPathSegment ?: return null
return runBlocking {
when (val result = SettingCovers.immutable(requireNotNull(context)).obtain(id)) {
is CoverResult.Hit -> result.cover.fd()
else -> null
}
}
}
override fun getType(uri: Uri): String {
check(uriMatcher.match(uri) == 1) { "Unknown URI: $uri" }
return "image/*"
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor = throw UnsupportedOperationException()
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int = 0
companion object {
private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.image.CoverProvider"
private const val IMAGES_PATH = "covers"
private val uriMatcher =
UriMatcher(UriMatcher.NO_MATCH).apply { addURI(AUTHORITY, "$IMAGES_PATH/*", 1) }
val CONTENT_URI: Uri =
Uri.Builder()
.scheme(ContentResolver.SCHEME_CONTENT)
.authority(AUTHORITY)
.appendPath(IMAGES_PATH)
.build()
}
}

View file

@ -18,7 +18,7 @@
package org.oxycblt.auxio.image
import android.animation.ValueAnimator
import android.animation.Animator
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
@ -33,36 +33,39 @@ import android.view.Gravity
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.annotation.AttrRes
import androidx.annotation.DimenRes
import androidx.annotation.DrawableRes
import androidx.core.content.res.getIntOrThrow
import androidx.annotation.Px
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.children
import androidx.core.view.isEmpty
import androidx.core.view.updateMarginsRelative
import androidx.core.widget.ImageViewCompat
import coil.ImageLoader
import coil.request.ImageRequest
import coil.util.CoilUtils
import coil3.ImageLoader
import coil3.asImage
import coil3.request.ImageRequest
import coil3.request.target
import coil3.request.transformations
import coil3.util.CoilUtils
import com.google.android.material.R as MR
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.RoundedRectTransformation
import org.oxycblt.auxio.image.extractor.SquareCropTransformation
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.image.coil.RoundedRectTransformation
import org.oxycblt.auxio.image.coil.SquareCropTransformation
import org.oxycblt.auxio.ui.MaterialFader
import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.covers.CoverCollection
/**
* Auxio's extension of [ImageView] that enables cover art loading and playing indicator and
@ -92,24 +95,41 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val playbackIndicator: PlaybackIndicator?
private val selectionBadge: ImageView?
private val iconSize: Int?
private val sizing: Int
@DimenRes private val iconSizeRes: Int?
@DimenRes private var cornerRadiusRes: Int?
private var fadeAnimator: ValueAnimator? = null
private val fader = MaterialFader.quickLopsided(context)
private var fadeAnimator: Animator? = null
private val indicatorMatrix = Matrix()
private val indicatorMatrixSrc = RectF()
private val indicatorMatrixDst = RectF()
private val shapeAppearance: ShapeAppearanceModel
init {
// Obtain some StyledImageView attributes to use later when theming the custom view.
@SuppressLint("CustomViewStyleable")
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.CoverView)
sizing = styledAttrs.getIntOrThrow(R.styleable.CoverView_sizing)
iconSizeRes = SIZING_ICON_SIZE[sizing]
cornerRadiusRes = getCornerRadiusRes()
val shapeAppearanceRes = styledAttrs.getResourceId(R.styleable.CoverView_shapeAppearance, 0)
shapeAppearance =
if (uiSettings.roundMode) {
if (shapeAppearanceRes != 0) {
ShapeAppearanceModel.builder(context, shapeAppearanceRes, -1).build()
} else {
ShapeAppearanceModel.builder(
context,
com.google.android.material.R.style
.ShapeAppearance_Material3_Corner_Medium,
-1)
.build()
}
} else {
ShapeAppearanceModel.builder().build()
}
iconSize =
styledAttrs.getDimensionPixelSize(R.styleable.CoverView_iconSize, -1).takeIf {
it != -1
}
val playbackIndicatorEnabled =
styledAttrs.getBoolean(R.styleable.CoverView_enablePlaybackIndicator, true)
@ -153,7 +173,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
super.onFinishInflate()
// The image isn't added if other children have populated the body. This is by design.
if (childCount == 0) {
if (isEmpty()) {
addView(image)
}
@ -183,7 +203,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// AnimatedVectorDrawable cannot be placed in a StyledDrawable, we must replicate the
// behavior with a matrix.
val playbackIndicator = (playbackIndicator ?: return).view
val iconSize = iconSizeRes?.let(context::getDimenPixels) ?: (measuredWidth / 2)
val iconSize = iconSize ?: (measuredWidth / 2)
playbackIndicator.apply {
imageMatrix =
indicatorMatrix.apply {
@ -247,14 +267,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
}
private fun getCornerRadiusRes() =
if (!isInEditMode && uiSettings.roundMode) {
SIZING_CORNER_RADII[sizing]
} else {
null
}
private fun applyBackgroundsToChildren() {
// Add backgrounds to each child for visual consistency
for (child in children) {
child.apply {
@ -264,7 +278,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
background =
MaterialShapeDrawable().apply {
fillColor = context.getColorCompat(R.color.sel_cover_bg)
setCornerSize(cornerRadiusRes?.let(context::getDimen) ?: 0f)
shapeAppearanceModel = shapeAppearance
}
}
}
@ -290,43 +304,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
private fun invalidateSelectionIndicatorAlpha(selectionBadge: ImageView) {
// Set up a target transition for the selection indicator.
val targetAlpha: Float
val targetDuration: Long
if (isActivated) {
// View is "activated" (i.e marked as selected), so show the selection indicator.
targetAlpha = 1f
targetDuration = context.getInteger(R.integer.anim_fade_enter_duration).toLong()
} else {
// View is not "activated", hide the selection indicator.
targetAlpha = 0f
targetDuration = context.getInteger(R.integer.anim_fade_exit_duration).toLong()
}
if (selectionBadge.alpha == targetAlpha) {
// Nothing to do.
return
}
if (!isLaidOut) {
// Not laid out, initialize it without animation before drawing.
selectionBadge.alpha = targetAlpha
return
}
if (fadeAnimator != null) {
// Cancel any previous animation.
fadeAnimator?.cancel()
fadeAnimator = null
}
fadeAnimator =
ValueAnimator.ofFloat(selectionBadge.alpha, targetAlpha).apply {
duration = targetDuration
addUpdateListener { selectionBadge.alpha = it.animatedValue as Float }
start()
}
(if (isActivated) fader.fadeIn(selectionBadge) else fader.fadeOut(selectionBadge))
.also { it.start() }
}
/**
@ -336,7 +317,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(song: Song) =
bindImpl(
listOf(song.cover),
song.cover,
context.getString(R.string.desc_album_cover, song.album.name),
R.drawable.ic_album_24)
@ -347,7 +328,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(album: Album) =
bindImpl(
album.cover.all,
album.covers,
context.getString(R.string.desc_album_cover, album.name),
R.drawable.ic_album_24)
@ -358,7 +339,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(artist: Artist) =
bindImpl(
artist.cover.all,
artist.covers,
context.getString(R.string.desc_artist_image, artist.name),
R.drawable.ic_artist_24)
@ -369,7 +350,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(genre: Genre) =
bindImpl(
genre.cover.all,
genre.covers,
context.getString(R.string.desc_genre_image, genre.name),
R.drawable.ic_genre_24)
@ -380,7 +361,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(playlist: Playlist) =
bindImpl(
playlist.cover?.all ?: emptyList(),
playlist.covers,
context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24)
@ -392,17 +373,21 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
*/
fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) =
bindImpl(Cover.order(songs), desc, errorRes)
bindImpl(CoverCollection.from(songs.mapNotNull { it.cover }), desc, errorRes)
private fun bindImpl(covers: List<Cover>, desc: String, @DrawableRes errorRes: Int) {
private fun bindImpl(cover: Any?, desc: String, @DrawableRes errorRes: Int) {
val request =
ImageRequest.Builder(context)
.data(covers)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes))
.data(cover)
.error(
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
.asImage())
.target(image)
val cornersTransformation =
RoundedRectTransformation(cornerRadiusRes?.let(context::getDimen) ?: 0f)
RoundedRectTransformation(
shapeAppearance.topLeftCornerSize.getCornerSize(
RectF(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat())))
if (imageSettings.forceSquareCovers) {
request.transformations(SquareCropTransformation.INSTANCE, cornersTransformation)
} else {
@ -422,7 +407,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private class StyledDrawable(
context: Context,
private val inner: Drawable,
@DimenRes iconSizeRes: Int?
@Px val iconSize: Int?
) : Drawable() {
init {
// Re-tint the drawable to use the analogous "on surface" color for
@ -430,12 +415,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
}
private val dimen = iconSizeRes?.let(context::getDimenPixels)
override fun draw(canvas: Canvas) {
// Resize the drawable such that it's always 1/4 the size of the image and
// centered in the middle of the canvas.
val adj = dimen?.let { (bounds.width() - it) / 2 } ?: (bounds.width() / 4)
val adj = iconSize?.let { (bounds.width() - it) / 2 } ?: (bounds.width() / 4)
inner.bounds.set(adj, adj, bounds.width() - adj, bounds.height() - adj)
inner.draw(canvas)
}
@ -452,11 +435,4 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
}
companion object {
val SIZING_CORNER_RADII =
arrayOf(
R.dimen.size_corners_small, R.dimen.size_corners_small, R.dimen.size_corners_medium)
val SIZING_ICON_SIZE = arrayOf(R.dimen.size_icon_small, R.dimen.size_icon_medium, null)
}
}

View file

@ -24,7 +24,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.oxycblt.auxio.R
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
import timber.log.Timber as L
/**
* User configuration specific to image loading.
@ -49,7 +49,7 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
get() =
CoverMode.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
?: CoverMode.MEDIA_STORE
?: CoverMode.BALANCED
override val forceSquareCovers: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_square_covers), false)
@ -58,14 +58,14 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
// Show album covers and Ignore MediaStore covers were unified in 3.0.0
if (sharedPreferences.contains(OLD_KEY_SHOW_COVERS) ||
sharedPreferences.contains(OLD_KEY_QUALITY_COVERS)) {
logD("Migrating cover settings")
L.d("Migrating cover settings")
val mode =
when {
!sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF
!sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) ->
CoverMode.MEDIA_STORE
else -> CoverMode.QUALITY
CoverMode.BALANCED
else -> CoverMode.BALANCED
}
sharedPreferences.edit {
@ -74,12 +74,30 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
remove(OLD_KEY_QUALITY_COVERS)
}
}
if (sharedPreferences.contains(OLD_KEY_COVER_MODE)) {
L.d("Migrating cover mode setting")
var mode =
CoverMode.fromIntCode(sharedPreferences.getInt(OLD_KEY_COVER_MODE, Int.MIN_VALUE))
?: CoverMode.BALANCED
if (mode == CoverMode.HIGH_QUALITY) {
// High quality now has space characteristics that could be
// undesirable, clamp to balanced.
mode = CoverMode.BALANCED
}
sharedPreferences.edit {
putInt(getString(R.string.set_key_cover_mode), mode.intCode)
remove(OLD_KEY_COVER_MODE)
}
}
}
override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
if (key == getString(R.string.set_key_cover_mode) ||
key == getString(R.string.set_key_square_covers)) {
logD("Dispatching image setting change")
L.d("Dispatching image setting change")
listener.onImageSettingsChanged()
}
}
@ -87,5 +105,6 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
private companion object {
const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_COVERS"
const val OLD_KEY_COVER_MODE = "auxio_cover_mode"
}
}

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* ExtractorModule.kt is part of Auxio.
* CoilModule.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,11 +16,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
package org.oxycblt.auxio.image.coil
import android.content.Context
import coil.ImageLoader
import coil.request.CachePolicy
import coil3.ImageLoader
import coil3.request.CachePolicy
import coil3.request.transitionFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -30,19 +31,22 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class ExtractorModule {
class CoilModule {
@Singleton
@Provides
fun imageLoader(
@ApplicationContext context: Context,
keyer: CoverKeyer,
factory: CoverFetcher.Factory
coverKeyer: CoverKeyer,
coverFactory: CoverFetcher.Factory,
coverCollectionKeyer: CoverCollectionKeyer,
coverCollectionFactory: CoverCollectionFetcher.Factory
) =
ImageLoader.Builder(context)
.components {
// Add fetchers for Music components to make them usable with ImageRequest
add(keyer)
add(factory)
add(coverKeyer)
add(coverFactory)
add(coverCollectionKeyer)
add(coverCollectionFactory)
}
// Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory())

View file

@ -0,0 +1,140 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverCollectionFetcher.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
import android.content.Context
import android.graphics.BitmapFactory
import android.graphics.Canvas
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.size.Dimension
import coil3.size.Size
import coil3.size.pxOrElse
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.withContext
import okio.FileSystem
import okio.buffer
import okio.source
import org.oxycblt.musikr.covers.CoverCollection
class CoverCollectionFetcher
private constructor(
private val context: Context,
private val covers: CoverCollection,
private val size: Size,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val streams = covers.covers.asFlow().mapNotNull { it.open() }.take(4).toList()
// We don't immediately check for mosaic feasibility from album count alone, as that
// does not factor in InputStreams failing to load. Instead, only check once we
// definitely have image data to use.
if (streams.size == 4) {
// Make sure we free the InputStreams once we've transformed them into a
// mosaic.
return createMosaic(streams, size).also {
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
}
}
// Not enough covers for a mosaic, take the first one (if that even exists)
val first = streams.firstOrNull() ?: return null
// All but the first stream will be unused, free their resources
withContext(Dispatchers.IO) {
for (i in 1 until streams.size) {
streams[i].close()
}
}
return SourceFetchResult(
source = ImageSource(first.source().buffer(), FileSystem.SYSTEM, null),
mimeType = null,
dataSource = DataSource.DISK)
}
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
// Use whatever size coil gives us to create the mosaic.
val mosaicSize = android.util.Size(size.width.mosaicSize(), size.height.mosaicSize())
val mosaicFrameSize =
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
val mosaicBitmap = createBitmap(mosaicSize.width, mosaicSize.height)
val canvas = Canvas(mosaicBitmap)
var x = 0
var y = 0
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
// and place it on a corner of the canvas.
for (stream in streams) {
if (y == mosaicSize.height) {
break
}
// Crop the bitmap down to a square so it leaves no empty space
// TODO: Work around this
val bitmap =
SquareCropTransformation.INSTANCE.transform(
BitmapFactory.decodeStream(stream), mosaicFrameSize)
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
x += bitmap.width
if (x == mosaicSize.width) {
x = 0
y += bitmap.height
}
}
// It's way easier to map this into a drawable then try to serialize it into an
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
// load low-res mosaics into high-res ImageViews.
return ImageFetchResult(
image = mosaicBitmap.toDrawable(context.resources).asImage(),
isSampled = true,
dataSource = DataSource.DISK)
}
private fun Dimension.mosaicSize(): Int {
// Since we want the mosaic to be perfectly divisible into two, we need to round any
// odd image sizes upwards to prevent the mosaic creation from failing.
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
}
class Factory @Inject constructor() : Fetcher.Factory<CoverCollection> {
override fun create(data: CoverCollection, options: Options, imageLoader: ImageLoader) =
CoverCollectionFetcher(options.context, data, options.size)
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverFetcher.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
import coil3.ImageLoader
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import javax.inject.Inject
import okio.FileSystem
import okio.buffer
import okio.source
import org.oxycblt.musikr.covers.Cover
class CoverFetcher private constructor(private val cover: Cover) : Fetcher {
override suspend fun fetch(): FetchResult? {
val stream = cover.open() ?: return null
return SourceFetchResult(
source = ImageSource(stream.source().buffer(), FileSystem.SYSTEM, null),
mimeType = null,
dataSource = DataSource.DISK)
}
class Factory @Inject constructor() : Fetcher.Factory<Cover> {
override fun create(data: Cover, options: Options, imageLoader: ImageLoader) =
CoverFetcher(data)
}
}

View file

@ -16,15 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
package org.oxycblt.auxio.image.coil
import coil.decode.DataSource
import coil.drawable.CrossfadeDrawable
import coil.request.ImageResult
import coil.request.SuccessResult
import coil.transition.CrossfadeTransition
import coil.transition.Transition
import coil.transition.TransitionTarget
import coil3.decode.DataSource
import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.transition.CrossfadeDrawable
import coil3.transition.CrossfadeTransition
import coil3.transition.Transition
import coil3.transition.TransitionTarget
/**
* A copy of [CrossfadeTransition.Factory] that also applies a transition to error results.

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2021 Auxio Project
* Keyers.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.coil
import coil3.key.Keyer
import coil3.request.Options
import javax.inject.Inject
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverCollection
class CoverKeyer @Inject constructor() : Keyer<Cover> {
override fun key(data: Cover, options: Options) = "${data.id}&${options.size}"
}
class CoverCollectionKeyer @Inject constructor() : Keyer<CoverCollection> {
override fun key(data: CoverCollection, options: Options) =
"multi:${data.hashCode()}&${options.size}"
}

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
package org.oxycblt.auxio.image.coil
import android.graphics.Bitmap
import android.graphics.Bitmap.createBitmap
@ -30,16 +30,16 @@ import android.graphics.RectF
import android.graphics.Shader
import androidx.annotation.Px
import androidx.core.graphics.applyCanvas
import coil.decode.DecodeUtils
import coil.size.Scale
import coil.size.Size
import coil.size.pxOrElse
import coil.transform.Transformation
import coil3.decode.DecodeUtils
import coil3.size.Scale
import coil3.size.Size
import coil3.size.pxOrElse
import coil3.transform.Transformation
import kotlin.math.roundToInt
/**
* A vendoring of [coil.transform.RoundedCornersTransformation] that can handle non-1:1 aspect ratio
* images without cropping them.
* A vendoring of coil's RoundedCornersTransformation that can handle non-1:1 aspect ratio images
* without cropping them.
*
* @author Coil Team, Alexander Capehart (OxygenCobalt)
*/
@ -48,7 +48,7 @@ class RoundedRectTransformation(
@Px private val topRight: Float = 0f,
@Px private val bottomLeft: Float = 0f,
@Px private val bottomRight: Float = 0f
) : Transformation {
) : Transformation() {
constructor(@Px radius: Float) : this(radius, radius, radius, radius)
@ -65,7 +65,11 @@ class RoundedRectTransformation(
val (outputWidth, outputHeight) = calculateOutputSize(input, size)
val output = createBitmap(outputWidth, outputHeight, input.config)
val output =
createBitmap(
outputWidth,
outputHeight,
requireNotNull(input.config) { "unsupported bitmap format" })
output.applyCanvas {
drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
@ -107,7 +111,10 @@ class RoundedRectTransformation(
}
private fun calculateOutputSize(input: Bitmap, size: Size): Pair<Int, Int> {
// MODIFICATION: Remove short-circuiting for original size and input size
if (size == Size.ORIGINAL) {
// This path only runs w/the widget code, which already normalizes widget sizes
return input.width to input.height
}
val multiplier =
DecodeUtils.computeSizeMultiplier(
srcWidth = input.width,

View file

@ -16,12 +16,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
package org.oxycblt.auxio.image.coil
import android.graphics.Bitmap
import coil.size.Size
import coil.size.pxOrElse
import coil.transform.Transformation
import androidx.core.graphics.scale
import coil3.size.Size
import coil3.size.pxOrElse
import coil3.transform.Transformation
import kotlin.math.min
/**
@ -30,7 +31,7 @@ import kotlin.math.min
*
* @author Alexander Capehart (OxygenCobalt)
*/
class SquareCropTransformation : Transformation {
class SquareCropTransformation : Transformation() {
override val cacheKey: String
get() = "SquareCropTransformation"
@ -46,7 +47,7 @@ class SquareCropTransformation : Transformation {
val desiredHeight = size.height.pxOrElse { dstSize }
if (dstSize != desiredWidth || dstSize != desiredHeight) {
// Image is not the desired size, upscale it.
return Bitmap.createScaledBitmap(dst, desiredWidth, desiredHeight, true)
return dst.scale(desiredWidth, desiredHeight)
}
return dst
}

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* DeviceModule.kt is part of Auxio.
* CoversModule.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.device
package org.oxycblt.auxio.image.covers
import dagger.Binds
import dagger.Module
@ -25,6 +25,6 @@ import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface DeviceModule {
@Binds fun deviceLibraryFactory(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory
interface CoilModule {
@Binds fun settingCovers(imageSettings: SettingCoversImpl): SettingCovers
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2024 Auxio Project
* NullCovers.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.covers
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.CoverResult
import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.covers.stored.CoverStorage
import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata
class NullCovers(private val storage: CoverStorage) : MutableCovers<NullCover> {
override suspend fun obtain(id: String) = CoverResult.Hit(NullCover)
override suspend fun create(file: DeviceFile, metadata: Metadata) = CoverResult.Hit(NullCover)
override suspend fun cleanup(excluding: Collection<Cover>) {
storage.ls(setOf()).map { storage.rm(it) }
}
}
data object NullCover : Cover {
override val id = "null"
override suspend fun open() = null
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Auxio Project
* RevisionedTranscoding.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.covers
import java.util.UUID
import org.oxycblt.musikr.covers.stored.Transcoding
class RevisionedTranscoding(revision: UUID, private val inner: Transcoding) : Transcoding by inner {
override val tag = "_$revision${inner.tag}"
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2024 Auxio Project
* SettingCovers.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.covers
import android.content.Context
import android.graphics.Bitmap
import java.util.UUID
import javax.inject.Inject
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.musikr.covers.Cover
import org.oxycblt.musikr.covers.Covers
import org.oxycblt.musikr.covers.FDCover
import org.oxycblt.musikr.covers.MutableCovers
import org.oxycblt.musikr.covers.chained.ChainedCovers
import org.oxycblt.musikr.covers.chained.MutableChainedCovers
import org.oxycblt.musikr.covers.embedded.CoverIdentifier
import org.oxycblt.musikr.covers.embedded.EmbeddedCovers
import org.oxycblt.musikr.covers.fs.FSCovers
import org.oxycblt.musikr.covers.fs.MutableFSCovers
import org.oxycblt.musikr.covers.stored.Compress
import org.oxycblt.musikr.covers.stored.CoverStorage
import org.oxycblt.musikr.covers.stored.MutableStoredCovers
import org.oxycblt.musikr.covers.stored.NoTranscoding
import org.oxycblt.musikr.covers.stored.StoredCovers
interface SettingCovers {
suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover>
companion object {
suspend fun immutable(context: Context): Covers<FDCover> =
ChainedCovers(StoredCovers(CoverStorage.at(context.coversDir())), FSCovers(context))
}
}
class SettingCoversImpl @Inject constructor(private val imageSettings: ImageSettings) :
SettingCovers {
override suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover> {
val coverStorage = CoverStorage.at(context.coversDir())
val transcoding =
when (imageSettings.coverMode) {
CoverMode.OFF -> return NullCovers(coverStorage)
CoverMode.SAVE_SPACE -> Compress(Bitmap.CompressFormat.JPEG, 500, 70)
CoverMode.BALANCED -> Compress(Bitmap.CompressFormat.JPEG, 750, 85)
CoverMode.HIGH_QUALITY -> Compress(Bitmap.CompressFormat.JPEG, 1000, 100)
CoverMode.AS_IS -> NoTranscoding
}
val revisionedTranscoding = RevisionedTranscoding(revision, transcoding)
val storedCovers =
MutableStoredCovers(
EmbeddedCovers(CoverIdentifier.md5()), coverStorage, revisionedTranscoding)
val fsCovers = MutableFSCovers(context)
return MutableChainedCovers(storedCovers, fsCovers)
}
}
private fun Context.coversDir() = filesDir.resolve("covers").apply { mkdirs() }

View file

@ -1,46 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
* Components.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
import coil.ImageLoader
import coil.fetch.Fetcher
import coil.key.Keyer
import coil.request.Options
import coil.size.Size
import javax.inject.Inject
class CoverKeyer @Inject constructor() : Keyer<Collection<Cover>> {
override fun key(data: Collection<Cover>, options: Options) =
"${data.map { it.key }.hashCode()}"
}
class CoverFetcher
private constructor(
private val covers: Collection<Cover>,
private val size: Size,
private val coverExtractor: CoverExtractor,
) : Fetcher {
override suspend fun fetch() = coverExtractor.extract(covers, size)
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Collection<Cover>> {
override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) =
CoverFetcher(data, options.size, coverExtractor)
}
}

View file

@ -1,66 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* Cover.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
import android.net.Uri
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Song
sealed interface Cover {
val key: String
val mediaStoreCoverUri: Uri
/**
* The song has an embedded cover art we support, so we can operate with it on a per-song basis.
*/
data class Embedded(val songCoverUri: Uri, val songUri: Uri, val perceptualHash: String) :
Cover {
override val mediaStoreCoverUri = songCoverUri
override val key = perceptualHash
}
/**
* We couldn't find any embedded cover art ourselves, but the android system might have some
* through a cover.jpg file or something similar.
*/
data class External(val albumCoverUri: Uri) : Cover {
override val mediaStoreCoverUri = albumCoverUri
override val key = albumCoverUri.toString()
}
companion object {
private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
fun order(songs: Collection<Song>) =
FALLBACK_SORT.songs(songs)
.map { it.cover }
.groupBy { it.key }
.entries
.sortedByDescending { it.value.size }
.map { it.value.first() }
}
}
data class ParentCover(val single: Cover, val all: List<Cover>) {
companion object {
fun from(song: Song, songs: Collection<Song>) = from(song.cover, songs)
fun from(src: Cover, songs: Collection<Song>) = ParentCover(src, Cover.order(songs))
}
}

View file

@ -1,249 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* CoverExtractor.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.media.MediaMetadataRetriever
import android.util.Size as AndroidSize
import androidx.core.graphics.drawable.toDrawable
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Metadata
import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.extractor.metadata.flac.PictureFrame
import androidx.media3.extractor.metadata.id3.ApicFrame
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.SourceResult
import coil.size.Dimension
import coil.size.Size
import coil.size.pxOrElse
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayInputStream
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.asDeferred
import kotlinx.coroutines.withContext
import okio.buffer
import okio.source
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logE
/**
* Provides functionality for extracting album cover information. Meant for internal use only.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class CoverExtractor
@Inject
constructor(
@ApplicationContext private val context: Context,
private val imageSettings: ImageSettings,
private val mediaSourceFactory: MediaSource.Factory
) {
/**
* Extract an image (in the form of [FetchResult]) to represent the given [Song]s.
*
* @param covers The [Cover]s to load.
* @param size The [Size] of the image to load.
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult]
* will be returned of a mosaic composed of four album covers ordered by
* [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned.
*/
suspend fun extract(covers: Collection<Cover>, size: Size): FetchResult? {
val streams = mutableListOf<InputStream>()
for (cover in covers) {
openCoverInputStream(cover)?.let(streams::add)
// We don't immediately check for mosaic feasibility from album count alone, as that
// does not factor in InputStreams failing to load. Instead, only check once we
// definitely have image data to use.
if (streams.size == 4) {
// Make sure we free the InputStreams once we've transformed them into a mosaic.
return createMosaic(streams, size).also {
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
}
}
}
// Not enough covers for a mosaic, take the first one (if that even exists)
val first = streams.firstOrNull() ?: return null
// All but the first stream will be unused, free their resources
withContext(Dispatchers.IO) {
for (i in 1 until streams.size) {
streams[i].close()
}
}
return SourceResult(
source = ImageSource(first.source().buffer(), context),
mimeType = null,
dataSource = DataSource.DISK)
}
fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
var stream: ByteArrayInputStream? = null
for (i in 0 until metadata.length()) {
// We can only extract pictures from two tags with this method, ID3v2's APIC or
// Vorbis picture comments.
val pic: ByteArray?
val type: Int
when (val entry = metadata.get(i)) {
is ApicFrame -> {
pic = entry.pictureData
type = entry.pictureType
}
is PictureFrame -> {
pic = entry.pictureData
type = entry.pictureType
}
else -> continue
}
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
stream = ByteArrayInputStream(pic)
break
} else if (stream == null) {
stream = ByteArrayInputStream(pic)
}
}
return stream
}
private suspend fun openCoverInputStream(cover: Cover) =
try {
when (cover) {
is Cover.Embedded ->
when (imageSettings.coverMode) {
CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover)
CoverMode.QUALITY -> extractQualityCover(cover)
}
is Cover.External -> {
extractMediaStoreCover(cover)
}
}
} catch (e: Exception) {
logE("Unable to extract album cover due to an error: $e")
null
}
private suspend fun extractQualityCover(cover: Cover.Embedded) =
extractExoplayerCover(cover)
?: extractAospMetadataCover(cover) ?: extractMediaStoreCover(cover)
private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? =
MediaMetadataRetriever().run {
// This call is time-consuming but it also doesn't seem to hold up the main thread,
// so it's probably fine not to wrap it.rmt
setDataSource(context, cover.songUri)
// Get the embedded picture from MediaMetadataRetriever, which will return a full
// ByteArray of the cover without any compression artifacts.
// If its null [i.e there is no embedded cover], than just ignore it and move on
embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
}
private suspend fun extractExoplayerCover(cover: Cover.Embedded): InputStream? {
val tracks =
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri))
.asDeferred()
.await()
// The metadata extraction process of ExoPlayer results in a dump of all metadata
// it found, which must be iterated through.
val metadata = tracks[0].getFormat(0).metadata
if (metadata == null || metadata.length() == 0) {
// No (parsable) metadata. This is also expected.
return null
}
return findCoverDataInMetadata(metadata)
}
private suspend fun extractMediaStoreCover(cover: Cover) =
// Eliminate any chance that this blocking call might mess up the loading process
withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(cover.mediaStoreCoverUri)
}
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
// Use whatever size coil gives us to create the mosaic.
val mosaicSize = AndroidSize(size.width.mosaicSize(), size.height.mosaicSize())
val mosaicFrameSize =
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
val mosaicBitmap =
Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(mosaicBitmap)
var x = 0
var y = 0
// For each stream, create a bitmap scaled to 1/4th of the mosaics combined size
// and place it on a corner of the canvas.
for (stream in streams) {
if (y == mosaicSize.height) {
break
}
// Crop the bitmap down to a square so it leaves no empty space
// TODO: Work around this
val bitmap =
SquareCropTransformation.INSTANCE.transform(
BitmapFactory.decodeStream(stream), mosaicFrameSize)
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
x += bitmap.width
if (x == mosaicSize.width) {
x = 0
y += bitmap.height
}
}
// It's way easier to map this into a drawable then try to serialize it into an
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
// load low-res mosaics into high-res ImageViews.
return DrawableResult(
drawable = mosaicBitmap.toDrawable(context.resources),
isSampled = true,
dataSource = DataSource.DISK)
}
private fun Dimension.mosaicSize(): Int {
// Since we want the mosaic to be perfectly divisible into two, we need to round any
// odd image sizes upwards to prevent the mosaic creation from failing.
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
}
}

View file

@ -1,60 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* DHash.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.extractor
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import java.math.BigInteger
@Suppress("UNUSED")
fun Bitmap.dHash(hashSize: Int = 16): String {
// Step 1: Resize the bitmap to a fixed size
val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)
// Step 2: Convert the bitmap to grayscale
val grayBitmap =
Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(grayBitmap)
val paint = Paint()
val colorMatrix = ColorMatrix()
colorMatrix.setSaturation(0f)
val filter = ColorMatrixColorFilter(colorMatrix)
paint.colorFilter = filter
canvas.drawBitmap(resizedBitmap, 0f, 0f, paint)
// Step 3: Compute the difference between adjacent pixels
var hash = BigInteger.valueOf(0)
val one = BigInteger.valueOf(1)
for (y in 0 until hashSize) {
for (x in 0 until hashSize) {
val pixel1 = grayBitmap.getPixel(x, y)
val pixel2 = grayBitmap.getPixel(x + 1, y)
val diff = Color.red(pixel1) - Color.red(pixel2)
if (diff > 0) {
hash = hash.or(one.shl(y * hashSize + x))
}
}
}
return hash.toString(16)
}

View file

@ -22,14 +22,16 @@ import androidx.annotation.StringRes
// TODO: Consider breaking this up into sealed classes for individual adapters
/** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
interface Item
typealias Item = Any
interface Header
/**
* A "header" used for delimiting groups of data.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface Header : Item {
interface PlainHeader : Header {
/** The string resource used for the header's title. */
val titleRes: Int
}
@ -40,12 +42,16 @@ interface Header : Item {
* @param titleRes The string resource used for the header's title.
* @author Alexander Capehart (OxygenCobalt)
*/
data class BasicHeader(@StringRes override val titleRes: Int) : Header
data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader
interface Divider<T> {
val anchor: T?
}
/**
* A divider decoration used to delimit groups of data.
*
* @param anchor The [Header] this divider should be next to in a list. Used as a way to preserve
* divider continuity during list updates.
* @param anchor The [PlainHeader] this divider should be next to in a list. Used as a way to
* preserve divider continuity during list updates.
*/
data class Divider(val anchor: Header?) : Item
data class PlainDivider(override val anchor: PlainHeader?) : Divider<PlainHeader>

View file

@ -20,7 +20,7 @@ package org.oxycblt.auxio.list
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import org.oxycblt.auxio.music.Music
import org.oxycblt.musikr.Music
/**
* A Fragment containing a selectable list.

View file

@ -26,7 +26,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.settings.Settings
interface ListSettings : Settings<Unit> {
interface ListSettings : Settings<ListSettings.Listener> {
/** The [Sort] mode used in Song lists. */
var songSort: Sort
/** The [Sort] mode used in Album lists. */
@ -43,10 +43,28 @@ interface ListSettings : Settings<Unit> {
var artistSongSort: Sort
/** The [Sort] mode used in a Genre's Song list. */
var genreSongSort: Sort
interface Listener {
fun onSongSortChanged() {}
fun onAlbumSortChanged() {}
fun onAlbumSongSortChanged() {}
fun onArtistSortChanged() {}
fun onArtistSongSortChanged() {}
fun onGenreSortChanged() {}
fun onGenreSongSortChanged() {}
fun onPlaylistSortChanged() {}
}
}
class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Context) :
Settings.Impl<Unit>(context), ListSettings {
Settings.Impl<ListSettings.Listener>(context), ListSettings {
override var songSort: Sort
get() =
Sort.fromIntCode(
@ -145,4 +163,17 @@ class ListSettingsImpl @Inject constructor(@ApplicationContext val context: Cont
apply()
}
}
override fun onSettingChanged(key: String, listener: ListSettings.Listener) {
when (key) {
getString(R.string.set_key_songs_sort) -> listener.onSongSortChanged()
getString(R.string.set_key_albums_sort) -> listener.onAlbumSortChanged()
getString(R.string.set_key_album_songs_sort) -> listener.onAlbumSongSortChanged()
getString(R.string.set_key_artists_sort) -> listener.onArtistSortChanged()
getString(R.string.set_key_artist_songs_sort) -> listener.onArtistSongSortChanged()
getString(R.string.set_key_genres_sort) -> listener.onGenreSortChanged()
getString(R.string.set_key_genre_songs_sort) -> listener.onGenreSongSortChanged()
getString(R.string.set_key_playlists_sort) -> listener.onPlaylistSortChanged()
}
}
}

View file

@ -25,19 +25,18 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L
/**
* A [ViewModel] that orchestrates menu dialogs and selection state.
@ -65,18 +64,17 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
}
override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary ?: return
val userLibrary = musicRepository.userLibrary ?: return
val library = musicRepository.library ?: return
// Sanitize the selection to remove items that no longer exist and thus
// won't appear in any list.
_selected.value =
_selected.value.mapNotNull {
when (it) {
is Song -> deviceLibrary.findSong(it.uid)
is Album -> deviceLibrary.findAlbum(it.uid)
is Artist -> deviceLibrary.findArtist(it.uid)
is Genre -> deviceLibrary.findGenre(it.uid)
is Playlist -> userLibrary.findPlaylist(it.uid)
is Song -> library.findSong(it.uid)
is Album -> library.findAlbum(it.uid)
is Artist -> library.findArtist(it.uid)
is Genre -> library.findGenre(it.uid)
is Playlist -> library.findPlaylist(it.uid)
}
}
}
@ -94,16 +92,16 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
*/
fun select(music: Music) {
if (music is MusicParent && music.songs.isEmpty()) {
logD("Cannot select empty parent, ignoring operation")
L.d("Cannot select empty parent, ignoring operation")
return
}
val selected = _selected.value.toMutableList()
if (!selected.remove(music)) {
logD("Adding $music to selection")
L.d("Adding $music to selection")
selected.add(music)
} else {
logD("Removed $music from selection")
L.d("Removed $music from selection")
}
_selected.value = selected
@ -131,7 +129,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @return A list of [Song]s collated from each item selected.
*/
fun takeSelection(): List<Song> {
logD("Taking selection")
L.d("Taking selection")
return peekSelection().also { _selected.value = listOf() }
}
@ -141,7 +139,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @return true if the prior selection was non-empty, false otherwise.
*/
fun dropSelection(): Boolean {
logD("Dropping selection [empty=${_selected.value.isEmpty()}]")
L.d("Dropping selection [empty=${_selected.value.isEmpty()}]")
return _selected.value.isNotEmpty().also { _selected.value = listOf() }
}
@ -155,7 +153,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* should do.
*/
fun openMenu(@MenuRes menuRes: Int, song: Song, playWith: PlaySong) {
logD("Opening menu for $song")
L.d("Opening menu for $song")
openImpl(Menu.ForSong(menuRes, song, playWith))
}
@ -167,7 +165,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @param album The [Album] to show.
*/
fun openMenu(@MenuRes menuRes: Int, album: Album) {
logD("Opening menu for $album")
L.d("Opening menu for $album")
openImpl(Menu.ForAlbum(menuRes, album))
}
@ -179,7 +177,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @param artist The [Artist] to show.
*/
fun openMenu(@MenuRes menuRes: Int, artist: Artist) {
logD("Opening menu for $artist")
L.d("Opening menu for $artist")
openImpl(Menu.ForArtist(menuRes, artist))
}
@ -191,7 +189,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @param genre The [Genre] to show.
*/
fun openMenu(@MenuRes menuRes: Int, genre: Genre) {
logD("Opening menu for $genre")
L.d("Opening menu for $genre")
openImpl(Menu.ForGenre(menuRes, genre))
}
@ -203,7 +201,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @param playlist The [Playlist] to show.
*/
fun openMenu(@MenuRes menuRes: Int, playlist: Playlist) {
logD("Opening menu for $playlist")
L.d("Opening menu for $playlist")
openImpl(Menu.ForPlaylist(menuRes, playlist))
}
@ -215,14 +213,14 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
* @param songs The [Song] selection to show.
*/
fun openMenu(@MenuRes menuRes: Int, songs: List<Song>) {
logD("Opening menu for ${songs.size} songs")
L.d("Opening menu for ${songs.size} songs")
openImpl(Menu.ForSelection(menuRes, songs))
}
private fun openImpl(menu: Menu) {
val existing = _menu.flow.value
if (existing != null) {
logW("Already opening $existing, ignoring $menu")
L.w("Already opening $existing, ignoring $menu")
return
}
_menu.put(menu)

View file

@ -25,7 +25,7 @@ import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import java.util.concurrent.Executor
import org.oxycblt.auxio.util.logD
import timber.log.Timber as L
/**
* A variant of ListDiffer with more flexible updates.
@ -57,7 +57,7 @@ abstract class FlexibleListAdapter<T, VH : RecyclerView.ViewHolder>(
instructions: UpdateInstructions?,
callback: (() -> Unit)? = null
) {
logD("Updating list to ${newList.size} items with $instructions")
L.d("Updating list to ${newList.size} items with $instructions")
differ.update(newList, instructions, callback)
}
}
@ -171,7 +171,7 @@ private class FlexibleListDiffer<T>(
) {
// fast simple remove all
if (newList.isEmpty()) {
logD("Short-circuiting diff to remove all")
L.d("Short-circuiting diff to remove all")
val countRemoved = oldList.size
currentList = emptyList()
// notify last, after list is updated
@ -182,7 +182,7 @@ private class FlexibleListDiffer<T>(
// fast simple first insert
if (oldList.isEmpty()) {
logD("Short-circuiting diff to insert all")
L.d("Short-circuiting diff to insert all")
currentList = newList
// notify last, after list is updated
updateCallback.onInserted(0, newList.size)
@ -244,7 +244,7 @@ private class FlexibleListDiffer<T>(
mainThreadExecutor.execute {
if (maxScheduledGeneration == runGeneration) {
logD("Applying calculated diff")
L.d("Applying calculated diff")
currentList = newList
result.dispatchUpdatesTo(updateCallback)
callback?.invoke()

View file

@ -21,8 +21,7 @@ package org.oxycblt.auxio.list.adapter
import android.view.View
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import timber.log.Timber as L
/**
* A [RecyclerView.Adapter] that supports indicating the playback status of a particular item.
@ -59,7 +58,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
* @param isPlaying Whether playback is ongoing or paused.
*/
fun setPlaying(item: T?, isPlaying: Boolean) {
logD("Updating playing item [old: $currentItem new: $item]")
L.d("Updating playing item [old: $currentItem new: $item]")
var updatedItem = false
if (currentItem != item) {
@ -72,7 +71,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
} else {
logW("oldItem was not in adapter data")
L.w("oldItem was not in adapter data")
}
}
@ -82,7 +81,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
} else {
logW("newItem was not in adapter data")
L.w("newItem was not in adapter data")
}
}
@ -100,7 +99,7 @@ abstract class PlayingIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
if (pos > -1) {
notifyItemChanged(pos, PAYLOAD_PLAYING_INDICATOR_CHANGED)
} else {
logW("newItem was not in adapter data")
L.w("newItem was not in adapter data")
}
}
}

View file

@ -21,8 +21,8 @@ package org.oxycblt.auxio.list.adapter
import android.view.View
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.util.logD
import org.oxycblt.musikr.Music
import timber.log.Timber as L
/**
* A [PlayingIndicatorAdapter] that also supports indicating the selection status of a group of
@ -55,7 +55,7 @@ abstract class SelectionIndicatorAdapter<T, VH : RecyclerView.ViewHolder>(
// Nothing to do.
return
}
logD("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}")
L.d("Updating selection [old=${oldSelectedItems.size} new=${newSelectedItems.size}")
selectedItems = newSelectedItems
for (i in currentList.indices) {

View file

@ -21,13 +21,13 @@ package org.oxycblt.auxio.list.menu
import android.os.Parcelable
import androidx.annotation.MenuRes
import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/**
* Command to navigate to a specific menu dialog configuration.

View file

@ -33,7 +33,7 @@ import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.ui.ViewBindingBottomSheetDialogFragment
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.logD
import timber.log.Timber as L
/**
* A [ViewBindingBottomSheetDialogFragment] that displays basic music information and a series of
@ -102,7 +102,7 @@ abstract class MenuDialogFragment<M : Menu> :
private fun updateMenu(menu: Menu?) {
if (menu == null) {
logD("No menu to show, navigating away")
L.d("No menu to show, navigating away")
findNavController().navigateUp()
return
}

View file

@ -27,17 +27,18 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMenuBinding
import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.share
import org.oxycblt.auxio.util.showToast
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/**
* [MenuDialogFragment] implementation for a [Song].
@ -112,7 +113,7 @@ class AlbumMenuDialogFragment : MenuDialogFragment<Menu.ForAlbum>() {
override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForAlbum) {
val context = requireContext()
binding.menuCover.bind(menu.album)
binding.menuType.text = getString(menu.album.releaseType.stringRes)
binding.menuType.text = menu.album.releaseType.resolve(context)
binding.menuName.text = menu.album.name.resolve(context)
binding.menuInfo.text = menu.album.artists.resolveNames(context)
}

View file

@ -82,7 +82,7 @@ class MenuItemViewHolder private constructor(private val binding: ItemMenuOption
oldItem == newItem
override fun areContentsTheSame(oldItem: MenuItem, newItem: MenuItem) =
oldItem.title == newItem.title
oldItem.title.toString() == newItem.title.toString()
}
}
}

View file

@ -23,10 +23,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.util.logW
import org.oxycblt.musikr.MusicParent
import timber.log.Timber as L
/**
* Manages the state information for [MenuDialogFragment] implementations.
@ -55,7 +55,7 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
fun setMenu(parcel: Menu.Parcel) {
_currentMenu.value = unpackParcel(parcel)
if (_currentMenu.value == null) {
logW("Given menu parcel $parcel was invalid")
L.w("Given menu parcel $parcel was invalid")
}
}
@ -70,35 +70,35 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
}
private fun unpackSongParcel(parcel: Menu.ForSong.Parcel): Menu.ForSong? {
val song = musicRepository.deviceLibrary?.findSong(parcel.songUid) ?: return null
val song = musicRepository.library?.findSong(parcel.songUid) ?: return null
val parent = parcel.playWithUid?.let(musicRepository::find) as MusicParent?
val playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null
return Menu.ForSong(parcel.res, song, playWith)
}
private fun unpackAlbumParcel(parcel: Menu.ForAlbum.Parcel): Menu.ForAlbum? {
val album = musicRepository.deviceLibrary?.findAlbum(parcel.albumUid) ?: return null
val album = musicRepository.library?.findAlbum(parcel.albumUid) ?: return null
return Menu.ForAlbum(parcel.res, album)
}
private fun unpackArtistParcel(parcel: Menu.ForArtist.Parcel): Menu.ForArtist? {
val artist = musicRepository.deviceLibrary?.findArtist(parcel.artistUid) ?: return null
val artist = musicRepository.library?.findArtist(parcel.artistUid) ?: return null
return Menu.ForArtist(parcel.res, artist)
}
private fun unpackGenreParcel(parcel: Menu.ForGenre.Parcel): Menu.ForGenre? {
val genre = musicRepository.deviceLibrary?.findGenre(parcel.genreUid) ?: return null
val genre = musicRepository.library?.findGenre(parcel.genreUid) ?: return null
return Menu.ForGenre(parcel.res, genre)
}
private fun unpackPlaylistParcel(parcel: Menu.ForPlaylist.Parcel): Menu.ForPlaylist? {
val playlist = musicRepository.userLibrary?.findPlaylist(parcel.playlistUid) ?: return null
val playlist = musicRepository.library?.findPlaylist(parcel.playlistUid) ?: return null
return Menu.ForPlaylist(parcel.res, playlist)
}
private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? {
val deviceLibrary = musicRepository.deviceLibrary ?: return null
val songs = parcel.songUids.mapNotNull(deviceLibrary::findSong)
val library = musicRepository.library ?: return null
val songs = parcel.songUids.mapNotNull(library::findSong)
return Menu.ForSelection(parcel.res, songs)
}
}

View file

@ -19,6 +19,7 @@
package org.oxycblt.auxio.list.recycler
import android.content.Context
import android.os.Parcelable
import android.util.AttributeSet
import android.view.WindowInsets
import androidx.annotation.AttrRes
@ -38,6 +39,7 @@ open class AuxioRecyclerView
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
RecyclerView(context, attrs, defStyleAttr) {
private val initialPaddingBottom = paddingBottom
private var savedState: Parcelable? = null
init {
// Prevent children from being clipped by window insets
@ -60,6 +62,18 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// Update the RecyclerView's padding such that the bottom insets are applied
// while still preserving bottom padding.
updatePadding(bottom = initialPaddingBottom + insets.systemBarInsetsCompat.bottom)
if (savedState != null) {
// State restore happens before we get insets, so there will be scroll drift unless
// we restore the state after the insets are applied.
// We must only do this once, otherwise we'll get jumpy behavior.
super.onRestoreInstanceState(savedState)
savedState = null
}
return insets
}
override fun onRestoreInstanceState(state: Parcelable?) {
super.onRestoreInstanceState(state)
savedState = state
}
}

View file

@ -16,13 +16,18 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.home.fastscroll
package org.oxycblt.auxio.list.recycler
import android.animation.Animator
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Rect
import android.os.Build
import android.text.TextUtils
import android.util.AttributeSet
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
@ -30,16 +35,22 @@ import android.view.ViewGroup
import android.view.WindowInsets
import android.widget.FrameLayout
import androidx.annotation.AttrRes
import androidx.core.view.isEmpty
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.core.view.updatePaddingRelative
import androidx.core.widget.TextViewCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textview.MaterialTextView
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.recycler.AuxioRecyclerView
import org.oxycblt.auxio.ui.MaterialFadingSlider
import org.oxycblt.auxio.ui.MaterialSlider
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.isRtl
import org.oxycblt.auxio.util.isUnder
import org.oxycblt.auxio.util.systemBarInsetsCompat
@ -66,52 +77,73 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* - Variable names are no longer prefixed with m
* - Added drag listener
* - Added documentation
* - Completely new design
* - New scroll position backend
*
* @author Hai Zhang, Alexander Capehart (OxygenCobalt)
*
* TODO: Add vibration when popup changes
* TODO: Improve support for variably sized items (Re-back with library fast scroller?)
*/
class FastScrollRecyclerView
@JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioRecyclerView(context, attrs, defStyleAttr) {
// Thumb
private val thumbView =
View(context).apply {
alpha = 0f
background = context.getDrawableCompat(R.drawable.ui_scroll_thumb)
}
private val thumbWidth = context.getDimenPixels(R.dimen.spacing_mid_medium)
private val thumbHeight = context.getDimenPixels(R.dimen.size_touchable_medium)
private val thumbSlider = MaterialSlider.small(context, thumbWidth)
private var thumbAnimator: Animator? = null
private val thumbWidth = thumbView.background.intrinsicWidth
private val thumbHeight = thumbView.background.intrinsicHeight
@SuppressLint("InflateParams")
private val thumbView =
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply {
thumbSlider.jumpOut(this)
}
private val thumbPadding = Rect(0, 0, 0, 0)
private var thumbOffset = 0
private var showingThumb = false
private val hideThumbRunnable = Runnable {
if (!dragging) {
hideScrollbar()
hideThumb()
}
}
// Popup
private val popupView =
FastScrollPopupView(context).apply {
MaterialTextView(context).apply {
minimumWidth = context.getDimenPixels(R.dimen.size_touchable_large)
minimumHeight = context.getDimenPixels(R.dimen.size_touchable_small)
TextViewCompat.setTextAppearance(this, R.style.TextAppearance_Auxio_HeadlineMedium)
setTextColor(
context.getAttrColorCompat(com.google.android.material.R.attr.colorOnSecondary))
ellipsize = TextUtils.TruncateAt.MIDDLE
gravity = Gravity.CENTER
includeFontPadding = false
elevation =
context
.getDimenPixels(com.google.android.material.R.dimen.m3_sys_elevation_level1)
.toFloat()
background = context.getDrawableCompat(R.drawable.ui_popup)
val paddingStart = context.getDimenPixels(R.dimen.spacing_medium)
val paddingEnd = paddingStart + context.getDimenPixels(R.dimen.spacing_tiny) / 2
updatePaddingRelative(start = paddingStart, end = paddingEnd)
layoutParams =
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
.apply {
marginEnd = context.getDimenPixels(R.dimen.size_touchable_small)
gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP
marginEnd = context.getDimenPixels(R.dimen.spacing_small)
}
}
private val popupSlider =
MaterialFadingSlider(MaterialSlider.large(context, popupView.minimumWidth / 2)).apply {
jumpOut(popupView)
}
private var popupAnimator: Animator? = null
private var showingPopup = false
// Touch
private val minTouchTargetSize =
context.getDimenPixels(R.dimen.fast_scroll_thumb_touch_target_size)
private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small)
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var downX = 0f
@ -120,6 +152,24 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private var dragStartY = 0f
private var dragStartThumbOffset = 0
private var fastScrollingPossible = true
var fastScrollingEnabled = true
set(value) {
if (field == value) {
return
}
field = value
if (!value) {
removeCallbacks(hideThumbRunnable)
hideThumb()
hidePopup()
}
listener?.onFastScrollingChanged(field)
}
private var dragging = false
set(value) {
if (field == value) {
@ -139,15 +189,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
showScrollbar()
showPopup()
} else {
postAutoHideScrollbar()
hidePopup()
postAutoHideScrollbar()
}
listener?.onFastScrollingChanged(field)
}
private val tRect = Rect()
var popupProvider: PopupProvider? = null
var listener: Listener? = null
@ -182,22 +230,22 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// --- RECYCLERVIEW EVENT MANAGEMENT ---
private fun onPreDraw() {
updateScrollbarState()
updateThumbState()
thumbView.layoutDirection = layoutDirection
popupView.layoutDirection = layoutDirection
thumbView.measure(
MeasureSpec.makeMeasureSpec(thumbWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY))
val thumbTop = thumbPadding.top + thumbOffset
val thumbLeft =
if (isRtl) {
thumbPadding.left
} else {
width - thumbPadding.right - thumbWidth
}
val thumbTop = thumbPadding.top + thumbOffset
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
popupView.layoutDirection = layoutDirection
val child = getChildAt(0)
val firstAdapterPos =
if (child != null) {
@ -214,10 +262,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
popupText = provider.getPopup(firstAdapterPos) ?: "?"
} else {
// No valid position or provider, do not show the popup.
popupView.isInvisible = true
popupView.isInvisible = false
popupText = ""
}
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
if (popupView.text != popupText) {
@ -243,6 +290,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
popupLayoutParams.height)
popupView.measure(widthMeasureSpec, heightMeasureSpec)
if (showingPopup) {
doPopupVibration()
}
}
val popupWidth = popupView.measuredWidth
@ -255,7 +305,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
val popupAnchorY = popupHeight / 2
val thumbAnchorY = thumbView.paddingTop
val thumbAnchorY = thumbView.height / 2
val popupTop =
(thumbTop + thumbAnchorY - popupAnchorY)
@ -269,7 +319,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
override fun onScrolled(dx: Int, dy: Int) {
super.onScrolled(dx, dy)
updateScrollbarState()
updateThumbState()
// Measure or layout events result in a fake onScrolled call. Ignore those.
if (dx == 0 && dy == 0) {
@ -287,30 +337,27 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
return insets
}
private fun updateScrollbarState() {
if (scrollRange <= height || childCount == 0) {
return
}
// Combine the previous item dimensions with the current item top to find our scroll
// position
getDecoratedBoundsWithMargins(getChildAt(0), tRect)
val child = getChildAt(0)
val firstAdapterPos =
when (val mgr = layoutManager) {
is GridLayoutManager -> mgr.getPosition(child) / mgr.spanCount
is LinearLayoutManager -> mgr.getPosition(child)
else -> 0
}
val scrollOffset = paddingTop + (firstAdapterPos * itemHeight) - tRect.top
private fun updateThumbState() {
// Then calculate the thumb position, which is just:
// [proportion of scroll position to scroll range] * [total thumb range]
thumbOffset = (thumbOffsetRange.toLong() * scrollOffset / scrollOffsetRange).toInt()
// This is somewhat adapted from the androidx RecyclerView FastScroller implementation.
val offsetY = computeVerticalScrollOffset()
if (computeVerticalScrollRange() < height || isEmpty()) {
fastScrollingPossible = false
hideThumb()
hidePopup()
return
}
val extentY = computeVerticalScrollExtent()
val fraction = (offsetY).toFloat() / (computeVerticalScrollRange() - extentY)
thumbOffset = (thumbOffsetRange * fraction).toInt()
}
private fun onItemTouch(event: MotionEvent): Boolean {
if (!fastScrollingEnabled || !fastScrollingPossible) {
dragging = false
return false
}
val eventX = event.x
val eventY = event.y
@ -324,10 +371,12 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) {
dragStartThumbOffset = thumbOffset
} else {
} else if (eventX > thumbView.right - thumbWidth / 4) {
dragStartThumbOffset =
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset)
} else {
return false
}
dragging = true
@ -364,44 +413,19 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
private fun scrollToThumbOffset(thumbOffset: Int) {
val clampedThumbOffset = thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
val scrollOffset =
(scrollOffsetRange.toLong() * clampedThumbOffset / thumbOffsetRange).toInt() -
paddingTop
scrollTo(scrollOffset)
}
private fun scrollTo(offset: Int) {
if (childCount == 0) {
val rangeY = computeVerticalScrollRange() - computeVerticalScrollExtent()
val previousThumbOffset = this.thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
val previousOffsetY = rangeY * (previousThumbOffset / thumbOffsetRange.toFloat())
val newThumbOffset = thumbOffset.coerceAtLeast(0).coerceAtMost(thumbOffsetRange)
val newOffsetY = rangeY * (newThumbOffset / thumbOffsetRange.toFloat())
if (newOffsetY == 0f) {
// Hacky workaround to drift in vertical scroll offset where we just snap
// to the top if the thumb offset hit zero.
scrollToPosition(0)
return
}
stopScroll()
val trueOffset = offset - paddingTop
val itemHeight = itemHeight
val firstItemPosition = 0.coerceAtLeast(trueOffset / itemHeight)
val firstItemTop = firstItemPosition * itemHeight - trueOffset
scrollToPositionWithOffset(firstItemPosition, firstItemTop)
}
private fun scrollToPositionWithOffset(position: Int, offset: Int) {
var targetPosition = position
val trueOffset = offset - paddingTop
when (val mgr = layoutManager) {
is GridLayoutManager -> {
targetPosition *= mgr.spanCount
mgr.scrollToPositionWithOffset(targetPosition, trueOffset)
}
is LinearLayoutManager -> {
mgr.scrollToPositionWithOffset(targetPosition, trueOffset)
}
}
val dy = newOffsetY - previousOffsetY
scrollBy(0, max(dy.roundToInt(), -computeVerticalScrollOffset()))
}
// --- SCROLLBAR APPEARANCE ---
@ -412,30 +436,39 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
private fun showScrollbar() {
if (!fastScrollingEnabled || !fastScrollingPossible) {
return
}
if (showingThumb) {
return
}
showingThumb = true
animateViewIn(thumbView)
thumbAnimator?.cancel()
thumbAnimator = thumbSlider.slideIn(thumbView).also { it.start() }
}
private fun hideScrollbar() {
private fun hideThumb() {
if (!showingThumb) {
return
}
showingThumb = false
animateViewOut(thumbView)
thumbAnimator?.cancel()
thumbAnimator = thumbSlider.slideOut(thumbView).also { it.start() }
}
private fun showPopup() {
if (!fastScrollingEnabled || !fastScrollingPossible) {
return
}
if (showingPopup) {
return
}
showingPopup = true
animateViewIn(popupView)
popupAnimator?.cancel()
popupAnimator = popupSlider.slideIn(popupView).also { it.start() }
}
private fun hidePopup() {
@ -444,23 +477,17 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
showingPopup = false
animateViewOut(popupView)
popupAnimator?.cancel()
popupAnimator = popupSlider.slideOut(popupView).also { it.start() }
}
private fun animateViewIn(view: View) {
view
.animate()
.alpha(1f)
.setDuration(context.getInteger(R.integer.anim_fade_enter_duration).toLong())
.start()
}
private fun animateViewOut(view: View) {
view
.animate()
.alpha(0f)
.setDuration(context.getInteger(R.integer.anim_fade_exit_duration).toLong())
.start()
private fun doPopupVibration() {
performHapticFeedback(
if (Build.VERSION.SDK_INT >= 27) {
HapticFeedbackConstants.TEXT_HANDLE_MOVE
} else {
HapticFeedbackConstants.KEYBOARD_TAP
})
}
// --- LAYOUT STATE ---
@ -470,45 +497,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
return height - thumbPadding.top - thumbPadding.bottom - thumbHeight
}
private val scrollRange: Int
get() {
val itemCount = itemCount
if (itemCount == 0) {
return 0
}
val itemHeight = itemHeight
return if (itemHeight != 0) {
paddingTop + itemCount * itemHeight + paddingBottom
} else {
0
}
}
private val scrollOffsetRange: Int
get() = scrollRange - height
private val itemHeight: Int
get() {
if (childCount == 0) {
return 0
}
val itemView = getChildAt(0)
getDecoratedBoundsWithMargins(itemView, tRect)
return tRect.height()
}
private val itemCount: Int
get() =
when (val mgr = layoutManager) {
is GridLayoutManager -> (mgr.itemCount - 1) / mgr.spanCount + 1
is LinearLayoutManager -> mgr.itemCount
else -> 0
}
/** An interface to provide text to use in the popup when fast-scrolling. */
interface PopupProvider {
/**
@ -532,6 +520,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
}
private companion object {
const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 1500
const val AUTO_HIDE_SCROLLBAR_DELAY_MILLIS = 500
}
}

View file

@ -25,6 +25,7 @@ import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.view.isInvisible
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.R as MR
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@ -33,7 +34,7 @@ import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder
import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.logD
import timber.log.Timber as L
/**
* A highly customized [ItemTouchHelper.Callback] that enables some extra eye candy in editable UIs,
@ -91,12 +92,11 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
// Hook drag events to "lifting" the item (i.e raising it's elevation). Make sure
// this is only done once when the item is initially picked up.
// TODO: I think this is possible to improve with a raw ValueAnimator.
if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
logD("Lifting ViewHolder")
L.d("Lifting ViewHolder")
val bg = holder.background
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
val elevation = recyclerView.context.getDimen(MR.dimen.m3_sys_elevation_level4)
holder.root
.animate()
.translationZ(elevation)
@ -135,10 +135,10 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
// This function can be called multiple times, so only start the animation when the view's
// translationZ is already non-zero.
if (holder.root.translationZ != 0f) {
logD("Lifting ViewHolder")
L.d("Lifting ViewHolder")
val bg = holder.background
val elevation = recyclerView.context.getDimen(R.dimen.elevation_normal)
val elevation = recyclerView.context.getDimen(MR.dimen.m3_sys_elevation_level4)
holder.root
.animate()
.translationZ(0f)

View file

@ -18,6 +18,7 @@
package org.oxycblt.auxio.list.recycler
import android.annotation.SuppressLint
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.divider.MaterialDivider
@ -27,20 +28,21 @@ import org.oxycblt.auxio.databinding.ItemHeaderBinding
import org.oxycblt.auxio.databinding.ItemParentBinding
import org.oxycblt.auxio.databinding.ItemSongBinding
import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Divider
import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.areNamesTheSame
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
/**
* A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.
@ -360,7 +362,7 @@ class BasicHeaderViewHolder private constructor(private val binding: ItemHeaderB
}
/**
* A [RecyclerView.ViewHolder] that displays a [Divider]. Use [from] to create an instance.
* A [RecyclerView.ViewHolder] that displays a [PlainDivider]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@ -381,8 +383,9 @@ class DividerViewHolder private constructor(divider: MaterialDivider) :
/** A comparator that can be used with DiffUtil. */
val DIFF_CALLBACK =
object : SimpleDiffCallback<Divider>() {
override fun areContentsTheSame(oldItem: Divider, newItem: Divider) =
object : SimpleDiffCallback<PlainDivider>() {
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: PlainDivider, newItem: PlainDivider) =
oldItem.anchor == newItem.anchor
}
}

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