Compare commits

...

556 commits
music2 ... 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
426 changed files with 14789 additions and 12096 deletions

View file

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

View file

@ -25,8 +25,10 @@ jobs:
cache: gradle cache: gradle
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew
- name: Test app with Gradle - name: Check formatting with spotless
run: ./gradlew app:testDebug run: ./gradlew spotlessCheck
- name: Test musikr with Gradle
run: ./gradlew musikr:testDebug
- name: Build debug APK with Gradle - name: Build debug APK with Gradle
run: ./gradlew app:packageDebug run: ./gradlew app:packageDebug
- name: Upload debug APK artifact - name: Upload debug APK artifact

2
.gitignore vendored
View file

@ -14,3 +14,5 @@ captures/
*.iml *.iml
.cxx .cxx
.kotlin .kotlin
.aider*
.env

5
.gitmodules vendored
View file

@ -1,3 +1,8 @@
[submodule "media"] [submodule "media"]
path = media path = media
url = https://github.com/OxygenCobalt/media.git 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,30 +1,100 @@
# Changelog # 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 ## 4.0.0
#### What's New #### 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 - Android 15 support
- New app branding and icon
- Refreshed playback design
- Live widget preview on Android 15+
- Added GitHub/email feedback forms to about page
#### What's Improved #### What's Improved
- Album grouping no longer done with artist in mind by default - 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 - 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 - 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 - Sorting songs by date now uses songs date first, before the earliest album date
- Added working layouts for small split-screen form factors - 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 #### What's Fixed
- Music loader no longer spawns thousands of threads when scanning
- Excessive CPU no longer spent showing music loading process
- Fixed playback sheet flickering on warm start - Fixed playback sheet flickering on warm start
- No longer possible to save a sort with no direction specified - No longer possible to save a sort with no direction specified
- Fixed inconsistent corner radii in widget - 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 #### Dev/Meta
- No longer using custom logging setup - No longer using custom logging setup
- Music loading split off into separate musikr module
## 3.6.3 ## 3.6.3

View file

View file

@ -2,8 +2,8 @@
<h1 align="center"><b>Auxio</b></h1> <h1 align="center"><b>Auxio</b></h1>
<h4 align="center">A simple, rational music player for android.</h4> <h4 align="center">A simple, rational music player for android.</h4>
<p align="center"> <p align="center">
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v3.6.3"> <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=v3.6.3&color=64B5F6&style=flat"> <img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v4.0.4&color=64B5F6&style=flat">
</a> </a>
<a href="https://github.com/oxygencobalt/Auxio/releases/"> <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"> <img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
@ -15,7 +15,12 @@
</p> </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> <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"> <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> <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> </p>
@ -28,14 +33,12 @@ Auxio is a local music player with a fast, reliable UI/UX without the many usele
## Screenshots ## Screenshots
<p align="center"> <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/shot0.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png" width=200> <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=200> <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=200> <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=200> <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=200> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png" width=250>
<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>
</p> </p>
@ -61,29 +64,39 @@ precise/original dates, sort tags, and more
- Headset autoplay - Headset autoplay
- Stylish widgets that automatically adapt to their size - Stylish widgets that automatically adapt to their size
- Completely private and offline - Completely private and offline
- No rounded album covers (by default) - No rounded album covers (if you want them)
## Permissions ## Permissions
- Storage (`READ_MEDIA_AUDIO`, `READ_EXTERNAL_STORAGE`) to read and play your music files - 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 - 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 ## Donate
You can support Auxio's development through [my Github Sponsors page](https://github.com/sponsors/OxygenCobalt). Get the ability to prioritize features and have your profile added to the README, Release Changelogs, and even the app itself! You can support Auxio's development through [my Github Sponsors page](https://github.com/sponsors/OxygenCobalt). Get the ability to prioritize features and have your profile added to the README, Release Changelogs, and even the app itself!
<p align="center"><b>$16/month supporters:</b></p>
<p align="center">
<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> <p align="center"><b>$8/month supporters:</b></p>
<p align="center"> <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/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/dmint789"><img src="https://avatars.githubusercontent.com/u/53250435?v=4" width=50 /></a>
<a href="https://github.com/yrliet"><img src="https://avatars.githubusercontent.com/u/151430565?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> </p>
## Building ## 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. 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 2. The project uses submodules, so when cloning initially, use `git clone --recurse-submodules` to properly
download the external code. download the external code.

View file

@ -2,7 +2,6 @@ plugins {
id "com.android.application" id "com.android.application"
id "kotlin-android" id "kotlin-android"
id "androidx.navigation.safeargs.kotlin" id "androidx.navigation.safeargs.kotlin"
id "com.diffplug.spotless"
id "kotlin-parcelize" id "kotlin-parcelize"
id "dagger.hilt.android.plugin" id "dagger.hilt.android.plugin"
id "kotlin-kapt" id "kotlin-kapt"
@ -12,20 +11,18 @@ plugins {
android { android {
compileSdk 35 compileSdk 35
// NDK is not used in Auxio explicitly (used in the ffmpeg extension), but we need to specify // Auxio implicitly depends on the native modules, explicitly specify it
// it here so that binary stripping will work. // here so the libraries are still stripped.
// TODO: Eventually you might just want to start vendoring the FFMpeg extension so the ndkVersion ndk_version
// NDK use is unified
ndkVersion "26.3.11579264"
namespace "org.oxycblt.auxio" namespace "org.oxycblt.auxio"
defaultConfig { defaultConfig {
applicationId namespace applicationId namespace
versionName "3.6.3" versionName "4.0.4"
versionCode 53 versionCode 63
minSdk 24 minSdk min_sdk
targetSdk 35 targetSdk target_sdk
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
@ -80,14 +77,13 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
def coroutines_version = '1.7.2' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$kotlin_coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version"
// --- SUPPORT --- // --- SUPPORT ---
// General // General
implementation "androidx.core:core-ktx:1.15.0" implementation "androidx.core:core-ktx:$core_version"
implementation "androidx.appcompat:appcompat:1.7.0" implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.activity:activity-ktx:1.9.3" implementation "androidx.activity:activity-ktx:1.9.3"
// noinspection GradleDependency // noinspection GradleDependency
@ -125,20 +121,26 @@ dependencies {
implementation "androidx.preference:preference-ktx:1.2.1" implementation "androidx.preference:preference-ktx:1.2.1"
// Database // Database
def room_version = '2.6.1'
implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-runtime:$room_version"
ksp "androidx.room:room-compiler:$room_version" ksp "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-ktx:$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 --- // --- THIRD PARTY ---
// Exoplayer (Vendored) // Exoplayer (Vendored)
implementation project(":media-lib-exoplayer") implementation project(":media-lib-exoplayer")
implementation project(":media-lib-decoder-ffmpeg") implementation project(":media-lib-decoder-ffmpeg")
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.3"
// Image loading // Image loading
implementation 'io.coil-kt:coil-base:2.4.0' implementation 'io.coil-kt.coil3:coil-core:3.0.2'
// Material // Material
// TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just // TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just
@ -162,25 +164,4 @@ dependencies {
// Fuzzy search // Fuzzy search
implementation 'org.apache.commons:commons-text:1.9' implementation 'org.apache.commons:commons-text:1.9'
// 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.6.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
}
spotless {
kotlin {
target "src/**/*.kt"
ktfmt().dropboxStyle()
licenseHeaderFile("NOTICE")
}
}
afterEvaluate {
preDebugBuild.dependsOn spotlessApply
} }

View file

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

View file

@ -2,9 +2,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> 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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@ -100,6 +97,15 @@
</intent-filter> </intent-filter>
</service> </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. Work around apps that blindly query for ACTION_MEDIA_BUTTON working.
See the class for more info. See the class for more info.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View file

@ -1309,7 +1309,6 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
+ " should not be set externally."); + " should not be set externally.");
} }
if (!hideable && state == STATE_HIDDEN) { if (!hideable && state == STATE_HIDDEN) {
Log.w(TAG, "Cannot set state: " + state);
return; return;
} }
final int finalState; final int finalState;
@ -1633,12 +1632,13 @@ public class BackportBottomSheetBehavior<V extends View> extends CoordinatorLayo
return; return;
} }
BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked(); BackEventCompat backEvent = bottomContainerBackHelper.onHandleBackInvoked();
boolean canActuallyHide = hideable && isHideableWhenDragging();
if (backEvent == null || VERSION.SDK_INT < VERSION_CODES.UPSIDE_DOWN_CAKE) { 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. // 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; return;
} }
if (hideable && isHideableWhenDragging()) { if (canActuallyHide) {
bottomContainerBackHelper.finishBackProgressNotPersistent( bottomContainerBackHelper.finishBackProgressNotPersistent(
backEvent, backEvent,
new AnimatorListenerAdapter() { new AnimatorListenerAdapter() {

View file

@ -36,6 +36,7 @@ import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.service.MusicServiceFragment import org.oxycblt.auxio.music.service.MusicServiceFragment
import org.oxycblt.auxio.playback.service.PlaybackServiceFragment import org.oxycblt.auxio.playback.service.PlaybackServiceFragment
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class AuxioService : class AuxioService :
@ -53,24 +54,30 @@ class AuxioService :
musicFragment = musicFragmentFactory.create(this, this, this) musicFragment = musicFragmentFactory.create(this, this, this)
sessionToken = playbackFragment.attach() sessionToken = playbackFragment.attach()
musicFragment.attach() musicFragment.attach()
Timber.d("Service Created")
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// TODO: Start command occurring from a foreign service basically implies a detached // TODO: Start command occurring from a foreign service basically implies a detached
// service, we might need more handling here. // service, we might need more handling here.
super.onStartCommand(intent, flags, startId)
onHandleForeground(intent) onHandleForeground(intent)
return super.onStartCommand(intent, flags, startId) // 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
} }
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
val binder = super.onBind(intent)
onHandleForeground(intent) onHandleForeground(intent)
return super.onBind(intent) return binder
} }
private fun onHandleForeground(intent: Intent?) { private fun onHandleForeground(intent: Intent?) {
val startId = intent?.getIntExtra(INTENT_KEY_START_ID, -1) ?: -1
musicFragment.start() musicFragment.start()
playbackFragment.start(startId) playbackFragment.start(intent)
} }
override fun onTaskRemoved(rootIntent: Intent?) { override fun onTaskRemoved(rootIntent: Intent?) {
@ -134,6 +141,7 @@ class AuxioService :
} }
// Nothing changed, but don't show anything music related since we can always // Nothing changed, but don't show anything music related since we can always
// index during playback. // index during playback.
isForeground = true
} else { } else {
musicFragment.createNotification { musicFragment.createNotification {
if (it != null) { if (it != null) {

View file

@ -65,6 +65,8 @@ object IntegerTable {
const val START_ID_ACTIVITY = 0xA050 const val START_ID_ACTIVITY = 0xA050
/** Tasker AuxioService Start ID */ /** Tasker AuxioService Start ID */
const val START_ID_TASKER = 0xA051 const val START_ID_TASKER = 0xA051
/** MediaButtonReceiver AuxioService Start ID */
const val START_ID_MEDIA_BUTTON = 0xA052
/** RepeatMode.NONE */ /** RepeatMode.NONE */
const val REPEAT_MODE_NONE = 0xA100 const val REPEAT_MODE_NONE = 0xA100
/** RepeatMode.ALL */ /** RepeatMode.ALL */
@ -123,10 +125,10 @@ object IntegerTable {
const val ACTION_MODE_SHUFFLE = 0xA11B const val ACTION_MODE_SHUFFLE = 0xA11B
/** CoverMode.Off */ /** CoverMode.Off */
const val COVER_MODE_OFF = 0xA11C const val COVER_MODE_OFF = 0xA11C
/** CoverMode.MediaStore */ /** CoverMode.Balanced */
const val COVER_MODE_MEDIA_STORE = 0xA11D const val COVER_MODE_BALANCED = 0xA11D
/** CoverMode.Quality */ /** CoverMode.Quality */
const val COVER_MODE_QUALITY = 0xA11E const val COVER_MODE_HIGH_QUALITY = 0xA11E
/** PlaySong.FromAll */ /** PlaySong.FromAll */
const val PLAY_SONG_FROM_ALL = 0xA11F const val PLAY_SONG_FROM_ALL = 0xA11F
/** PlaySong.FromAlbum */ /** PlaySong.FromAlbum */
@ -139,4 +141,8 @@ object IntegerTable {
const val PLAY_SONG_FROM_PLAYLIST = 0xA123 const val PLAY_SONG_FROM_PLAYLIST = 0xA123
/** PlaySong.ByItself */ /** PlaySong.ByItself */
const val PLAY_SONG_BY_ITSELF = 0xA124 const val PLAY_SONG_BY_ITSELF = 0xA124
/** CoverMode.SaveSpace */
const val COVER_MODE_SAVE_SPACE = 0xA125
/** CoverMode.AsIs */
const val COVER_MODE_AS_IS = 0xA126
} }

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio package org.oxycblt.auxio
import android.animation.ValueAnimator
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
@ -27,6 +26,7 @@ import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
@ -50,10 +50,8 @@ import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.home.Outer import org.oxycblt.auxio.home.Outer
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.OpenPanel import org.oxycblt.auxio.playback.OpenPanel
import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
@ -70,6 +68,8 @@ import org.oxycblt.auxio.util.getDimen
import org.oxycblt.auxio.util.lazyReflectedMethod import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -257,9 +257,9 @@ class MainFragment :
} }
override fun onPreDraw(): Boolean { override fun onPreDraw(): Boolean {
// TODO: Due to draw caching even *this* isn't effective enough to avoid the bottom // This is where I shove literally all the UI logic that won't behave any callback
// sheets continually getting stuck. I need something with even more frequent updates, // or "normal" method I've tried. Surely running this on every frame will actually cause
// or otherwise bottom sheets get stuck. // it to work properly!
// We overload CoordinatorLayout far too much to rely on any of it's typical // We overload CoordinatorLayout far too much to rely on any of it's typical
// listener functionality. Just update all transitions before every draw. Should // listener functionality. Just update all transitions before every draw. Should
@ -367,6 +367,10 @@ class MainFragment :
requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" } requireNotNull(sheetBackCallback) { "SheetBackPressedCallback was not available" }
.invalidateEnabled() .invalidateEnabled()
// Stop the FrameLayout containing the fabs from eating touch events elsewhere
binding.mainFabContainer.isVisible =
binding.homeNewPlaylistFab.mainFab.isVisible || binding.homeShuffleFab.isVisible
return true return true
} }
@ -404,9 +408,6 @@ class MainFragment :
} }
private fun updateIndexerState(state: IndexingState?) { 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
if (state is IndexingState.Completed && state.error == null) { if (state is IndexingState.Completed && state.error == null) {
L.d("Received ok response") L.d("Received ok response")
val binding = requireBinding() val binding = requireBinding()
@ -512,8 +513,6 @@ class MainFragment :
} }
} }
private var scrimAnimator: ValueAnimator? = null
private fun updateSpeedDial(open: Boolean) { private fun updateSpeedDial(open: Boolean) {
requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" } requireNotNull(speedDialBackCallback) { "SpeedDialBackPressedCallback was not available" }
.invalidateEnabled(open) .invalidateEnabled(open)

View file

@ -22,6 +22,7 @@ import android.os.Bundle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.databinding.FragmentDetailBinding
@ -29,12 +30,9 @@ import org.oxycblt.auxio.detail.list.AlbumDetailListAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu 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.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage 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.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
@ -44,6 +42,10 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull 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 import timber.log.Timber as L
/** /**
@ -115,7 +117,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
binding.detailToolbarTitle.text = name binding.detailToolbarTitle.text = name
binding.detailCover.bind(album) binding.detailCover.bind(album)
// The type text depends on the release type (Album, EP, Single, etc.) // The type text depends on the release type (Album, EP, Single, etc.)
binding.detailType.text = getString(album.releaseType.stringRes) binding.detailType.text = album.releaseType.resolve(context)
binding.detailName.text = name binding.detailName.text = name
// Artist name maps to the subhead text // Artist name maps to the subhead text
binding.detailSubhead.apply { binding.detailSubhead.apply {
@ -131,7 +133,7 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
// Date, song count, and duration map to the info text // Date, song count, and duration map to the info text
binding.detailInfo.apply { binding.detailInfo.apply {
// Fall back to a friendlier "No date" text if the album doesn't have date information // 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 date = album.dates?.resolve(context) ?: context.getString(R.string.def_date)
val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size) val songCount = context.getPlural(R.plurals.fmt_song_count, album.songs.size)
val duration = album.durationMs.formatDurationMs(true) val duration = album.durationMs.formatDurationMs(true)
text = context.getString(R.string.fmt_three, date, songCount, duration) text = context.getString(R.string.fmt_three, date, songCount, duration)
@ -140,9 +142,15 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
binding.detailPlayButton?.setOnClickListener { binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value)) playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
} }
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentAlbum.value))
}
binding.detailShuffleButton?.setOnClickListener { binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value)) playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
} }
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentAlbum.value))
}
updatePlayback( updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value) playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
} }
@ -291,6 +299,11 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
// RecyclerView will scroll assuming it has the total height of the screen (i.e a // 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. // collapsed appbar), so we need to collapse the appbar if that's the case.
binding.detailAppbar.setExpanded(false) 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 { binding.detailRecycler.post {
// Use a custom smooth scroller that will settle the item in the middle of // Use a custom smooth scroller that will settle the item in the middle of
// the screen rather than the end. // the screen rather than the end.
@ -316,4 +329,6 @@ class AlbumDetailFragment : DetailFragment<Album, Song>() {
} }
} }
} }
private fun RecyclerView.canScroll() = computeVerticalScrollRange() > height
} }

View file

@ -29,13 +29,9 @@ import org.oxycblt.auxio.detail.list.ArtistDetailListAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu 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.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage 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.music.resolveNames
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
@ -44,6 +40,11 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull 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 import timber.log.Timber as L
/** /**
@ -163,9 +164,15 @@ class ArtistDetailFragment : DetailFragment<Artist, Music>() {
binding.detailPlayButton?.setOnClickListener { binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value)) playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
} }
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentArtist.value))
}
binding.detailShuffleButton?.setOnClickListener { binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value)) playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
} }
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentArtist.value))
}
updatePlayback( updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value) playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
} }

View file

@ -35,13 +35,13 @@ import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.PlainDivider import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.PlainHeader import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.overrideOnOverflowMenuClick
import org.oxycblt.auxio.util.setFullWidthLookup import org.oxycblt.auxio.util.setFullWidthLookup
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.MusicParent
abstract class DetailFragment<P : MusicParent, C : Music> : abstract class DetailFragment<P : MusicParent, C : Music> :
ListFragment<C, FragmentDetailBinding>(), ListFragment<C, FragmentDetailBinding>(),
@ -123,6 +123,9 @@ abstract class DetailFragment<P : MusicParent, C : Music> :
val detailContent = binding.detailToolbarContent val detailContent = binding.detailToolbarContent
detailContent.alpha = inRatio detailContent.alpha = inRatio
detailContent.translationY = spacingSmall * (1 - inRatio) detailContent.translationY = spacingSmall * (1 - inRatio)
// Enable fast scrolling once fully collapsed
binding.detailRecycler.fastScrollingEnabled = ratio == 1f
} }
abstract fun onOpenParentMenu() abstract fun onOpenParentMenu()

View file

@ -23,17 +23,17 @@ import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.sort.Sort 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.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist import org.oxycblt.musikr.Album
import org.oxycblt.auxio.music.Song import org.oxycblt.musikr.Artist
import org.oxycblt.auxio.music.info.Disc import org.oxycblt.musikr.Genre
import org.oxycblt.auxio.music.info.ReleaseType 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 import timber.log.Timber as L
interface DetailGenerator { interface DetailGenerator {
@ -121,7 +121,7 @@ private class DetailGeneratorImpl(
} }
override fun album(uid: Music.UID): Detail<Album>? { override fun album(uid: Music.UID): Detail<Album>? {
val album = musicRepository.deviceLibrary?.findAlbum(uid) ?: return null val album = musicRepository.library?.findAlbum(uid) ?: return null
val songs = listSettings.albumSongSort.songs(album.songs) val songs = listSettings.albumSongSort.songs(album.songs)
val discs = songs.groupBy { it.disc } val discs = songs.groupBy { it.disc }
val section = val section =
@ -134,7 +134,7 @@ private class DetailGeneratorImpl(
} }
override fun artist(uid: Music.UID): Detail<Artist>? { override fun artist(uid: Music.UID): Detail<Artist>? {
val artist = musicRepository.deviceLibrary?.findArtist(uid) ?: return null val artist = musicRepository.library?.findArtist(uid) ?: return null
val grouping = val grouping =
artist.explicitAlbums.groupByTo(sortedMapOf()) { artist.explicitAlbums.groupByTo(sortedMapOf()) {
// Remap the complicated ReleaseType data structure into detail sections // Remap the complicated ReleaseType data structure into detail sections
@ -173,14 +173,14 @@ private class DetailGeneratorImpl(
} }
override fun genre(uid: Music.UID): Detail<Genre>? { override fun genre(uid: Music.UID): Detail<Genre>? {
val genre = musicRepository.deviceLibrary?.findGenre(uid) ?: return null val genre = musicRepository.library?.findGenre(uid) ?: return null
val artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists)) val artists = DetailSection.Artists(GENRE_ARTIST_SORT.artists(genre.artists))
val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs)) val songs = DetailSection.Songs(listSettings.genreSongSort.songs(genre.songs))
return Detail(genre, listOf(artists, songs)) return Detail(genre, listOf(artists, songs))
} }
override fun playlist(uid: Music.UID): Detail<Playlist>? { override fun playlist(uid: Music.UID): Detail<Playlist>? {
val playlist = musicRepository.userLibrary?.findPlaylist(uid) ?: return null val playlist = musicRepository.library?.findPlaylist(uid) ?: return null
if (playlist.songs.isNotEmpty()) { if (playlist.songs.isNotEmpty()) {
val songs = DetailSection.Songs(playlist.songs) val songs = DetailSection.Songs(playlist.songs)
return Detail(playlist, listOf(songs)) return Detail(playlist, listOf(songs))

View file

@ -22,16 +22,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.detail.list.DiscDivider import org.oxycblt.auxio.detail.list.DiscDivider
import org.oxycblt.auxio.detail.list.DiscHeader import org.oxycblt.auxio.detail.list.DiscHeader
import org.oxycblt.auxio.detail.list.EditHeader 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.detail.list.SortHeader
import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.BasicHeader
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
@ -40,21 +38,20 @@ import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.PlainHeader import org.oxycblt.auxio.list.PlainHeader
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.list.sort.Sort 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.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.metadata.AudioProperties
import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
import org.oxycblt.auxio.util.unlikelyToBeNull 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 import timber.log.Timber as L
/** /**
@ -69,11 +66,11 @@ class DetailViewModel
constructor( constructor(
private val listSettings: ListSettings, private val listSettings: ListSettings,
private val musicRepository: MusicRepository, private val musicRepository: MusicRepository,
private val audioPropertiesFactory: AudioProperties.Factory,
private val playbackSettings: PlaybackSettings, private val playbackSettings: PlaybackSettings,
detailGeneratorFactory: DetailGenerator.Factory detailGeneratorFactory: DetailGenerator.Factory
) : ViewModel(), DetailGenerator.Invalidator { ) : ViewModel(), DetailGenerator.Invalidator {
private val _toShow = MutableEvent<Show>() private val _toShow = MutableEvent<Show>()
/** /**
* A [Show] command that is awaiting a view capable of responding to it. Null if none currently. * A [Show] command that is awaiting a view capable of responding to it. Null if none currently.
*/ */
@ -82,30 +79,34 @@ constructor(
// --- SONG --- // --- SONG ---
private var currentSongJob: Job? = null
private val _currentSong = MutableStateFlow<Song?>(null) private val _currentSong = MutableStateFlow<Song?>(null)
/** The current [Song] to display. Null if there is nothing to show. */ /** The current [Song] to display. Null if there is nothing to show. */
val currentSong: StateFlow<Song?> val currentSong: StateFlow<Song?>
get() = _currentSong get() = _currentSong
private val _songAudioProperties = MutableStateFlow<AudioProperties?>(null) private val _currentSongProperties = MutableStateFlow<List<SongProperty>>(listOf())
/** The [AudioProperties] of the currently shown [Song]. Null if not loaded yet. */
val songAudioProperties: StateFlow<AudioProperties?> = _songAudioProperties /** The current properties of [currentSong]. Empty if nothing to show. */
val currentSongProperties: StateFlow<List<SongProperty>>
get() = _currentSongProperties
// --- ALBUM --- // --- ALBUM ---
private val _currentAlbum = MutableStateFlow<Album?>(null) private val _currentAlbum = MutableStateFlow<Album?>(null)
/** The current [Album] to display. Null if there is nothing to show. */ /** The current [Album] to display. Null if there is nothing to show. */
val currentAlbum: StateFlow<Album?> val currentAlbum: StateFlow<Album?>
get() = _currentAlbum get() = _currentAlbum
private val _albumSongList = MutableStateFlow(listOf<Item>()) private val _albumSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentAlbum]. */ /** The current list data derived from [currentAlbum]. */
val albumSongList: StateFlow<List<Item>> val albumSongList: StateFlow<List<Item>>
get() = _albumSongList get() = _albumSongList
private val _albumSongInstructions = MutableEvent<UpdateInstructions>() private val _albumSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [albumSongList] in the UI. */ /** Instructions for updating [albumSongList] in the UI. */
val albumSongInstructions: Event<UpdateInstructions> val albumSongInstructions: Event<UpdateInstructions>
get() = _albumSongInstructions get() = _albumSongInstructions
@ -121,15 +122,18 @@ constructor(
// --- ARTIST --- // --- ARTIST ---
private val _currentArtist = MutableStateFlow<Artist?>(null) private val _currentArtist = MutableStateFlow<Artist?>(null)
/** The current [Artist] to display. Null if there is nothing to show. */ /** The current [Artist] to display. Null if there is nothing to show. */
val currentArtist: StateFlow<Artist?> val currentArtist: StateFlow<Artist?>
get() = _currentArtist get() = _currentArtist
private val _artistSongList = MutableStateFlow(listOf<Item>()) private val _artistSongList = MutableStateFlow(listOf<Item>())
/** The current list derived from [currentArtist]. */ /** The current list derived from [currentArtist]. */
val artistSongList: StateFlow<List<Item>> = _artistSongList val artistSongList: StateFlow<List<Item>> = _artistSongList
private val _artistSongInstructions = MutableEvent<UpdateInstructions>() private val _artistSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistSongList] in the UI. */ /** Instructions for updating [artistSongList] in the UI. */
val artistSongInstructions: Event<UpdateInstructions> val artistSongInstructions: Event<UpdateInstructions>
get() = _artistSongInstructions get() = _artistSongInstructions
@ -145,15 +149,18 @@ constructor(
// --- GENRE --- // --- GENRE ---
private val _currentGenre = MutableStateFlow<Genre?>(null) private val _currentGenre = MutableStateFlow<Genre?>(null)
/** The current [Genre] to display. Null if there is nothing to show. */ /** The current [Genre] to display. Null if there is nothing to show. */
val currentGenre: StateFlow<Genre?> val currentGenre: StateFlow<Genre?>
get() = _currentGenre get() = _currentGenre
private val _genreSongList = MutableStateFlow(listOf<Item>()) private val _genreSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentGenre]. */ /** The current list data derived from [currentGenre]. */
val genreSongList: StateFlow<List<Item>> = _genreSongList val genreSongList: StateFlow<List<Item>> = _genreSongList
private val _genreSongInstructions = MutableEvent<UpdateInstructions>() private val _genreSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [artistSongList] in the UI. */ /** Instructions for updating [artistSongList] in the UI. */
val genreSongInstructions: Event<UpdateInstructions> val genreSongInstructions: Event<UpdateInstructions>
get() = _genreSongInstructions get() = _genreSongInstructions
@ -169,20 +176,24 @@ constructor(
// --- PLAYLIST --- // --- PLAYLIST ---
private val _currentPlaylist = MutableStateFlow<Playlist?>(null) private val _currentPlaylist = MutableStateFlow<Playlist?>(null)
/** The current [Playlist] to display. Null if there is nothing to do. */ /** The current [Playlist] to display. Null if there is nothing to do. */
val currentPlaylist: StateFlow<Playlist?> val currentPlaylist: StateFlow<Playlist?>
get() = _currentPlaylist get() = _currentPlaylist
private val _playlistSongList = MutableStateFlow(listOf<Item>()) private val _playlistSongList = MutableStateFlow(listOf<Item>())
/** The current list data derived from [currentPlaylist] */ /** The current list data derived from [currentPlaylist] */
val playlistSongList: StateFlow<List<Item>> = _playlistSongList val playlistSongList: StateFlow<List<Item>> = _playlistSongList
private val _playlistSongInstructions = MutableEvent<UpdateInstructions>() private val _playlistSongInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for updating [playlistSongList] in the UI. */ /** Instructions for updating [playlistSongList] in the UI. */
val playlistSongInstructions: Event<UpdateInstructions> val playlistSongInstructions: Event<UpdateInstructions>
get() = _playlistSongInstructions get() = _playlistSongInstructions
private val _editedPlaylist = MutableStateFlow<List<Song>?>(null) private val _editedPlaylist = MutableStateFlow<List<Song>?>(null)
/** /**
* The new playlist songs created during the current editing session. Null if no editing session * The new playlist songs created during the current editing session. Null if no editing session
* is occurring. * is occurring.
@ -308,14 +319,14 @@ constructor(
} }
/** /**
* Set a new [currentSong] from it's [Music.UID]. [currentSong] and [songAudioProperties] will * Set a new [currentSong] from it's [Music.UID]. [currentSong] will be updated to align with
* be updated to align with the new [Song]. * the new [Song].
* *
* @param uid The UID of the [Song] to load. Must be valid. * @param uid The UID of the [Song] to load. Must be valid.
*/ */
fun setSong(uid: Music.UID) { fun setSong(uid: Music.UID) {
L.d("Opening song $uid") L.d("Opening song $uid")
_currentSong.value = musicRepository.deviceLibrary?.findSong(uid)?.also(::refreshAudioInfo) _currentSong.value = musicRepository.library?.findSong(uid)?.also(::refreshAudioInfo)
if (_currentSong.value == null) { if (_currentSong.value == null) {
L.w("Given song UID was invalid") L.w("Given song UID was invalid")
} }
@ -511,16 +522,32 @@ constructor(
} }
private fun refreshAudioInfo(song: Song) { private fun refreshAudioInfo(song: Song) {
L.d("Refreshing audio info") _currentSongProperties.value = buildList {
// Clear any previous job in order to avoid stale data from appearing in the UI. add(SongProperty(R.string.lbl_name, SongProperty.Value.MusicName(song)))
currentSongJob?.cancel() add(SongProperty(R.string.lbl_album, SongProperty.Value.MusicName(song.album)))
_songAudioProperties.value = null add(SongProperty(R.string.lbl_artists, SongProperty.Value.MusicNames(song.artists)))
currentSongJob = add(SongProperty(R.string.lbl_genres, SongProperty.Value.MusicNames(song.genres)))
viewModelScope.launch(Dispatchers.IO) { song.date?.let { add(SongProperty(R.string.lbl_date, SongProperty.Value.ItemDate(it))) }
val info = audioPropertiesFactory.extract(song) song.track?.let {
yield() add(SongProperty(R.string.lbl_track, SongProperty.Value.Number(it, null)))
L.d("Updating audio info to $info") }
_songAudioProperties.value = info 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)))
}
} }
} }

View file

@ -29,13 +29,9 @@ import org.oxycblt.auxio.detail.list.GenreDetailListAdapter
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu 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.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage 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.PlaybackDecision
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
@ -43,6 +39,11 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull 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 import timber.log.Timber as L
/** /**
@ -132,9 +133,15 @@ class GenreDetailFragment : DetailFragment<Genre, Music>() {
binding.detailPlayButton?.setOnClickListener { binding.detailPlayButton?.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value)) playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
} }
binding.detailToolbarPlay.setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentGenre.value))
}
binding.detailShuffleButton?.setOnClickListener { binding.detailShuffleButton?.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value)) playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
} }
binding.detailToolbarShuffle.setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentGenre.value))
}
updatePlayback( updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value) playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
} }

View file

@ -35,13 +35,9 @@ import org.oxycblt.auxio.detail.list.PlaylistDragCallback
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.ListFragment import org.oxycblt.auxio.list.ListFragment
import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.ui.DialogAwareNavigationListener import org.oxycblt.auxio.ui.DialogAwareNavigationListener
@ -52,6 +48,11 @@ import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
import org.oxycblt.auxio.util.unlikelyToBeNull 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 import timber.log.Timber as L
/** /**
@ -231,12 +232,24 @@ class PlaylistDetailFragment :
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value)) playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
} }
} }
binding.detailToolbarPlay.apply {
isEnabled = playable
setOnClickListener {
playbackModel.play(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
binding.detailShuffleButton?.apply { binding.detailShuffleButton?.apply {
isEnabled = playable isEnabled = playable
setOnClickListener { setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value)) playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
} }
} }
binding.detailToolbarShuffle.apply {
isEnabled = playable
setOnClickListener {
playbackModel.shuffle(unlikelyToBeNull(detailModel.currentPlaylist.value))
}
}
updatePlayback( updatePlayback(
playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value) playbackModel.song.value, playbackModel.parent.value, playbackModel.isPlaying.value)
} }

View file

@ -18,9 +18,7 @@
package org.oxycblt.auxio.detail package org.oxycblt.auxio.detail
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.text.format.Formatter
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
@ -32,16 +30,9 @@ import org.oxycblt.auxio.databinding.DialogSongDetailBinding
import org.oxycblt.auxio.detail.list.SongProperty import org.oxycblt.auxio.detail.list.SongProperty
import org.oxycblt.auxio.detail.list.SongPropertyAdapter import org.oxycblt.auxio.detail.list.SongPropertyAdapter
import org.oxycblt.auxio.list.adapter.UpdateInstructions 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.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.concatLocalized import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -71,74 +62,19 @@ class SongDetailDialog : ViewBindingMaterialDialogFragment<DialogSongDetailBindi
// DetailViewModel handles most initialization from the navigation argument. // DetailViewModel handles most initialization from the navigation argument.
detailModel.setSong(args.songUid) detailModel.setSong(args.songUid)
detailModel.toShow.consume() 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?) {
if (song == null) {
L.d("No song to show, navigating away") L.d("No song to show, navigating away")
if (song == null) {
findNavController().navigateUp() findNavController().navigateUp()
return 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 { private fun updateSongProperties(songProperties: List<SongProperty>) {
val name = name detailAdapter.update(songProperties, UpdateInstructions.Replace(0))
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 <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.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView 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.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Artist
/** /**
* A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with * A [FlexibleListAdapter] that displays a list of [Artist] navigation choices, for use with

View file

@ -23,12 +23,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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.MusicRepository
import org.oxycblt.auxio.music.Song import org.oxycblt.musikr.Album
import org.oxycblt.auxio.music.device.DeviceLibrary 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 import timber.log.Timber as L
/** /**
@ -56,9 +56,9 @@ class DetailPickerViewModel @Inject constructor(private val musicRepository: Mus
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.deviceLibrary) return 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. // Need to sanitize different items depending on the current set of choices.
_artistChoices.value = _artistChoices.value?.sanitize(deviceLibrary) _artistChoices.value = _artistChoices.value?.sanitize(library)
L.d("Updated artist choices: ${_artistChoices.value}") L.d("Updated artist choices: ${_artistChoices.value}")
} }
@ -98,16 +98,15 @@ sealed interface ArtistShowChoices {
val uid: Music.UID val uid: Music.UID
/** The current [Artist] choices. */ /** The current [Artist] choices. */
val choices: List<Artist> val choices: List<Artist>
/** Sanitize this instance with a [DeviceLibrary]. */ /** Sanitize this instance with a [Library]. */
fun sanitize(newLibrary: DeviceLibrary): ArtistShowChoices? fun sanitize(newLibrary: Library): ArtistShowChoices?
/** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */ /** Backing implementation of [ArtistShowChoices] that is based on a [Song]. */
class FromSong(val song: Song) : ArtistShowChoices { class FromSong(val song: Song) : ArtistShowChoices {
override val uid = song.uid override val uid = song.uid
override val choices = song.artists override val choices = song.artists
override fun sanitize(newLibrary: DeviceLibrary) = override fun sanitize(newLibrary: Library) = newLibrary.findSong(uid)?.let { FromSong(it) }
newLibrary.findSong(uid)?.let { FromSong(it) }
} }
/** Backing implementation of [ArtistShowChoices] that is based on an [Album]. */ /** Backing implementation of [ArtistShowChoices] that is based on an [Album]. */
@ -115,7 +114,7 @@ sealed interface ArtistShowChoices {
override val uid = album.uid override val uid = album.uid
override val choices = album.artists override val choices = album.artists
override fun sanitize(newLibrary: DeviceLibrary) = override fun sanitize(newLibrary: Library) =
newLibrary.findAlbum(uid)?.let { FromAlbum(it) } newLibrary.findAlbum(uid)?.let { FromAlbum(it) }
} }
} }

View file

@ -32,9 +32,9 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Artist
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -35,14 +35,14 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.resolveNumber
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
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. * An [DetailListAdapter] implementing the header and sub-items for the [Album] detail view.
@ -121,7 +121,7 @@ private class DiscHeaderViewHolder(private val binding: ItemDiscHeaderBinding) :
*/ */
fun bind(discHeader: DiscHeader) { fun bind(discHeader: DiscHeader) {
val disc = discHeader.inner val disc = discHeader.inner
binding.discNumber.text = disc.resolveNumber(binding.context) binding.discNumber.text = disc.resolve(binding.context)
binding.discName.apply { binding.discName.apply {
text = disc?.name text = disc?.name
isGone = disc?.name == null isGone = disc?.name == null

View file

@ -29,12 +29,13 @@ import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater 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. * 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.parentName.text = album.name.resolve(binding.context)
binding.parentInfo.text = binding.parentInfo.text =
// Fall back to a friendlier "No date" text if the album doesn't have date information // Fall back to a friendlier "No date" text if the album doesn't have date information
album.dates?.resolveDate(binding.context) album.dates?.resolve(binding.context) ?: binding.context.getString(R.string.def_date)
?: binding.context.getString(R.string.def_date)
} }
override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) { override fun updatePlayingIndicator(isActive: Boolean, isPlaying: Boolean) {

View file

@ -35,9 +35,9 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder import org.oxycblt.auxio.list.recycler.BasicHeaderViewHolder
import org.oxycblt.auxio.list.recycler.DividerViewHolder import org.oxycblt.auxio.list.recycler.DividerViewHolder
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater 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 * A [RecyclerView.Adapter] that implements shared behavior between lists of child items in the

View file

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

View file

@ -40,12 +40,13 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.MaterialDragCallback import org.oxycblt.auxio.list.recycler.MaterialDragCallback
import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -18,17 +18,26 @@
package org.oxycblt.auxio.detail.list package org.oxycblt.auxio.detail.list
import android.text.format.Formatter
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.ItemSongPropertyBinding 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.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView 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.context
import org.oxycblt.auxio.util.inflater 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. * An adapter for [SongProperty] instances.
@ -53,7 +62,31 @@ class SongPropertyAdapter :
* @param value The value of the property. * @param value The value of the property.
* @author Alexander Capehart (OxygenCobalt) * @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. * 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) { fun bind(property: SongProperty) {
val context = binding.context val context = binding.context
binding.propertyName.hint = context.getString(property.name) 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 { companion object {

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Album
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Artist
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Genre
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -26,8 +26,8 @@ import org.oxycblt.auxio.databinding.DialogSortBinding
import org.oxycblt.auxio.detail.DetailViewModel import org.oxycblt.auxio.detail.DetailViewModel
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.list.sort.SortDialog import org.oxycblt.auxio.list.sort.SortDialog
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.musikr.Playlist
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

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

View file

@ -22,10 +22,10 @@ import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.MenuCompat import androidx.core.view.MenuCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
@ -37,12 +37,10 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.AppBarLayout 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.tabs.TabLayoutMediator
import com.google.android.material.transition.MaterialSharedAxis import com.google.android.material.transition.MaterialSharedAxis
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.lang.reflect.Field import java.lang.reflect.Field
import java.lang.reflect.Method
import kotlin.math.abs import kotlin.math.abs
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeBinding import org.oxycblt.auxio.databinding.FragmentHomeBinding
@ -53,31 +51,27 @@ import org.oxycblt.auxio.home.list.ArtistListFragment
import org.oxycblt.auxio.home.list.GenreListFragment import org.oxycblt.auxio.home.list.GenreListFragment
import org.oxycblt.auxio.home.list.PlaylistListFragment import org.oxycblt.auxio.home.list.PlaylistListFragment
import org.oxycblt.auxio.home.list.SongListFragment 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.home.tabs.Tab
import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.list.ListViewModel
import org.oxycblt.auxio.list.SelectionFragment import org.oxycblt.auxio.list.SelectionFragment
import org.oxycblt.auxio.list.menu.Menu import org.oxycblt.auxio.list.menu.Menu
import org.oxycblt.auxio.music.IndexingProgress
import org.oxycblt.auxio.music.IndexingState import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.MusicViewModel 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.PlaylistDecision
import org.oxycblt.auxio.music.PlaylistMessage import org.oxycblt.auxio.music.PlaylistMessage
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackDecision
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.lazyReflectedField
import org.oxycblt.auxio.util.lazyReflectedMethod
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.auxio.util.showToast 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 import timber.log.Timber as L
/** /**
@ -178,6 +172,7 @@ class HomeFragment :
// --- VIEWMODEL SETUP --- // --- VIEWMODEL SETUP ---
collect(homeModel.recreateTabs.flow, ::handleRecreate) collect(homeModel.recreateTabs.flow, ::handleRecreate)
collect(homeModel.chooseMusicLocations.flow, ::handleChooseFolders)
collectImmediately(homeModel.currentTabType, ::updateCurrentTab) collectImmediately(homeModel.currentTabType, ::updateCurrentTab)
collect(detailModel.toShow.flow, ::handleShow) collect(detailModel.toShow.flow, ::handleShow)
collect(listModel.menu.flow, ::handleMenu) collect(listModel.menu.flow, ::handleMenu)
@ -271,9 +266,7 @@ class HomeFragment :
// Set up the mapping between the ViewPager and TabLayout. // Set up the mapping between the ViewPager and TabLayout.
TabLayoutMediator( TabLayoutMediator(
binding.homeTabs, binding.homeTabs, binding.homePager, NamedTabStrategy(homeModel.currentTabTypes))
binding.homePager,
AdaptiveTabStrategy(requireContext(), homeModel.currentTabTypes))
.attach() .attach()
} }
@ -304,98 +297,49 @@ class HomeFragment :
homeModel.recreateTabs.consume() homeModel.recreateTabs.consume()
} }
private fun updateIndexerState(state: IndexingState?) { private fun handleChooseFolders(unit: Unit?) {
// TODO: Make music loading experience a bit more pleasant if (unit == null) {
// 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 -> {
L.d("Indexer is in indeterminate state")
binding.homeIndexingContainer.visibility = View.INVISIBLE
}
}
}
private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) {
if (error == null) {
L.d("Received ok response")
binding.homeIndexingContainer.visibility = View.INVISIBLE
return return
} }
findNavController().navigateSafe(HomeFragmentDirections.chooseLocations())
L.d("Received non-ok response") homeModel.chooseMusicLocations.consume()
val context = requireContext()
binding.homeIndexingContainer.visibility = View.VISIBLE
binding.homeIndexingProgress.visibility = View.INVISIBLE
binding.homeIndexingActions.visibility = View.VISIBLE
when (error) {
is NoAudioPermissionException -> {
L.d("Showing permission prompt")
binding.homeIndexingStatus.setText(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 -> {
L.d("Showing no music error")
binding.homeIndexingStatus.setText(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 -> {
L.d("Showing generic error")
binding.homeIndexingStatus.setText(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))
}
}
}
}
} }
private fun setupIndexingState(binding: FragmentHomeBinding, progress: IndexingProgress) { private fun updateIndexerState(state: IndexingState?) {
// Remove all content except for the progress indicator. val binding = requireBinding()
binding.homeIndexingContainer.visibility = View.VISIBLE when (state) {
binding.homeIndexingProgress.visibility = View.VISIBLE is IndexingState.Completed -> {
binding.homeIndexingActions.visibility = View.INVISIBLE binding.homeIndexingContainer.isInvisible = state.error == null
binding.homeIndexingProgress.isInvisible = state.error != null
binding.homeIndexingStatus.setText(R.string.lng_indexing) binding.homeIndexingError.isInvisible = state.error == null
when (progress) { if (state.error != null) {
is IndexingProgress.Indeterminate -> { binding.homeIndexingContainer.setOnClickListener {
// In a query/initialization state, show a generic loading status. findNavController()
binding.homeIndexingProgress.isIndeterminate = true .navigateSafe(HomeFragmentDirections.reportError(state.error))
} }
is IndexingProgress.Songs -> { } else {
// Actively loading songs, show the current progress. binding.homeIndexingContainer.setOnClickListener(null)
}
}
is IndexingState.Indexing -> {
binding.homeIndexingContainer.isInvisible = false
binding.homeIndexingProgress.apply { binding.homeIndexingProgress.apply {
isInvisible = false
when (state.progress) {
is IndexingProgress.Songs -> {
isIndeterminate = false isIndeterminate = false
max = progress.total progress = state.progress.loaded
this.progress = progress.current max = state.progress.explored
} }
is IndexingProgress.Indeterminate -> {
isIndeterminate = true
}
}
}
binding.homeIndexingError.isInvisible = true
}
null -> {
binding.homeIndexingContainer.isInvisible = true
} }
} }
} }
@ -564,11 +508,5 @@ class HomeFragment :
private companion object { private companion object {
val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView") val VP_RECYCLER_FIELD: Field by lazyReflectedField(ViewPager2::class, "mRecyclerView")
val RV_TOUCH_SLOP_FIELD: Field by lazyReflectedField(RecyclerView::class, "mTouchSlop") 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)
} }
} }

View file

@ -22,13 +22,13 @@ import javax.inject.Inject
import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
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.MusicRepository
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.Playlist import org.oxycblt.musikr.Album
import org.oxycblt.auxio.music.Song 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 import timber.log.Timber as L
interface HomeGenerator { interface HomeGenerator {
@ -36,6 +36,8 @@ interface HomeGenerator {
fun release() fun release()
fun empty(): Boolean
fun songs(): List<Song> fun songs(): List<Song>
fun albums(): List<Album> fun albums(): List<Album>
@ -49,6 +51,8 @@ interface HomeGenerator {
fun tabs(): List<MusicType> fun tabs(): List<MusicType>
interface Invalidator { interface Invalidator {
fun invalidateEmpty() {}
fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) fun invalidateMusic(type: MusicType, instructions: UpdateInstructions)
fun invalidateTabs() fun invalidateTabs()
@ -119,8 +123,10 @@ private class HomeGeneratorImpl(
} }
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary invalidator.invalidateEmpty()
if (changes.deviceLibrary && deviceLibrary != null) {
val library = musicRepository.library
if (changes.deviceLibrary && library != null) {
L.d("Refreshing library") L.d("Refreshing library")
// Get the each list of items in the library to use as our list data. // Get the each list of items in the library to use as our list data.
// Applying the preferred sorting to them. // Applying the preferred sorting to them.
@ -130,8 +136,7 @@ private class HomeGeneratorImpl(
invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff) invalidator.invalidateMusic(MusicType.GENRES, UpdateInstructions.Diff)
} }
val userLibrary = musicRepository.userLibrary if (changes.userLibrary && library != null) {
if (changes.userLibrary && userLibrary != null) {
L.d("Refreshing playlists") L.d("Refreshing playlists")
invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff) invalidator.invalidateMusic(MusicType.PLAYLISTS, UpdateInstructions.Diff)
} }
@ -143,15 +148,16 @@ private class HomeGeneratorImpl(
homeSettings.unregisterListener(this) homeSettings.unregisterListener(this)
} }
override fun empty() = musicRepository.library?.empty() ?: true
override fun songs() = override fun songs() =
musicRepository.deviceLibrary?.let { listSettings.songSort.songs(it.songs) } ?: emptyList() musicRepository.library?.let { listSettings.songSort.songs(it.songs) } ?: emptyList()
override fun albums() = override fun albums() =
musicRepository.deviceLibrary?.let { listSettings.albumSort.albums(it.albums) } musicRepository.library?.let { listSettings.albumSort.albums(it.albums) } ?: emptyList()
?: emptyList()
override fun artists() = override fun artists() =
musicRepository.deviceLibrary?.let { deviceLibrary -> musicRepository.library?.let { deviceLibrary ->
val sorted = listSettings.artistSort.artists(deviceLibrary.artists) val sorted = listSettings.artistSort.artists(deviceLibrary.artists)
if (homeSettings.shouldHideCollaborators) { if (homeSettings.shouldHideCollaborators) {
sorted.filter { it.explicitAlbums.isNotEmpty() } sorted.filter { it.explicitAlbums.isNotEmpty() }
@ -161,11 +167,10 @@ private class HomeGeneratorImpl(
} ?: emptyList() } ?: emptyList()
override fun genres() = override fun genres() =
musicRepository.deviceLibrary?.let { listSettings.genreSort.genres(it.genres) } musicRepository.library?.let { listSettings.genreSort.genres(it.genres) } ?: emptyList()
?: emptyList()
override fun playlists() = override fun playlists() =
musicRepository.userLibrary?.let { listSettings.playlistSort.playlists(it.playlists) } musicRepository.library?.let { listSettings.playlistSort.playlists(it.playlists) }
?: emptyList() ?: emptyList()
override fun tabs() = homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type } override fun tabs() = homeSettings.homeTabs.filterIsInstance<Tab.Visible>().map { it.type }

View file

@ -27,16 +27,16 @@ import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.list.adapter.UpdateInstructions
import org.oxycblt.auxio.list.sort.Sort 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.MusicType 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.PlaySong
import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.PlaybackSettings
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
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 import timber.log.Timber as L
/** /**
@ -120,6 +120,10 @@ constructor(
val playlistList: StateFlow<List<Playlist>> val playlistList: StateFlow<List<Playlist>>
get() = _playlistList get() = _playlistList
private val _empty = MutableStateFlow(false)
val empty: StateFlow<Boolean>
get() = _empty
private val _playlistInstructions = MutableEvent<UpdateInstructions>() private val _playlistInstructions = MutableEvent<UpdateInstructions>()
/** Instructions for how to update [genreList] in the UI. */ /** Instructions for how to update [genreList] in the UI. */
val playlistInstructions: Event<UpdateInstructions> val playlistInstructions: Event<UpdateInstructions>
@ -159,6 +163,10 @@ constructor(
val showOuter: Event<Outer> val showOuter: Event<Outer>
get() = _showOuter get() = _showOuter
private val _chooseMusicLocations = MutableEvent<Unit>()
val chooseMusicLocations: Event<Unit>
get() = _chooseMusicLocations
init { init {
homeGenerator.attach() homeGenerator.attach()
} }
@ -168,6 +176,10 @@ constructor(
homeGenerator.release() homeGenerator.release()
} }
override fun invalidateEmpty() {
_empty.value = homeGenerator.empty()
}
override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) { override fun invalidateMusic(type: MusicType, instructions: UpdateInstructions) {
when (type) { when (type) {
MusicType.SONGS -> { MusicType.SONGS -> {
@ -263,6 +275,10 @@ constructor(
_isFastScrolling.value = isFastScrolling _isFastScrolling.value = isFastScrolling
} }
fun startChooseMusicLocations() {
_chooseMusicLocations.put(Unit)
}
fun showSettings() { fun showSettings() {
_showOuter.put(Outer.Settings) _showOuter.put(Outer.Settings)
} }

View file

@ -190,6 +190,8 @@ class ThemedSpeedDialView : SpeedDialView {
val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f) val overlayColor = surfaceColor.defaultColor.withModulatedAlpha(0.87f)
overlayLayout.setBackgroundColor(overlayColor) overlayLayout.setBackgroundColor(overlayColor)
} }
// Fix default margins added by library
(mainFab.layoutParams as LayoutParams).setMargins(0, 0, 0, 0)
} }
private fun Int.withModulatedAlpha( private fun Int.withModulatedAlpha(
@ -230,13 +232,24 @@ class ThemedSpeedDialView : SpeedDialView {
return super.addActionItem(actionItem, position, animate)?.apply { return super.addActionItem(actionItem, position, animate)?.apply {
fab.apply { fab.apply {
updateLayoutParams<MarginLayoutParams> { updateLayoutParams<MarginLayoutParams> {
val horizontalMargin = context.getDimenPixels(R.dimen.spacing_mid_large) val rightMargin = context.getDimenPixels(R.dimen.spacing_tiny)
setMargins(horizontalMargin, 0, horizontalMargin, 0) 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 useCompatPadding = false
} }
labelBackground.apply { labelBackground.apply {
updateLayoutParams<MarginLayoutParams> {
if (position == actionItems.lastIndex) {
val bottomMargin = context.getDimenPixels(R.dimen.spacing_small)
setMargins(0, 0, rightMargin, bottomMargin)
}
}
useCompatPadding = false useCompatPadding = false
setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall) setContentPadding(spacingSmall, spacingSmall, spacingSmall, spacingSmall)
background = background =

View file

@ -22,6 +22,8 @@ import android.os.Bundle
import android.text.format.DateUtils import android.text.format.DateUtils
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter import java.util.Formatter
@ -36,15 +38,16 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.AlbumViewHolder import org.oxycblt.auxio.list.recycler.AlbumViewHolder
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel 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.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.util.collectImmediately 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. * A [ListFragment] that shows a list of [Album]s.
@ -79,7 +82,16 @@ class AlbumListFragment :
listener = this@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.albumList, ::updateAlbums)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) 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. // Change how we display the popup depending on the current sort mode.
return when (homeModel.albumSort.mode) { return when (homeModel.albumSort.mode) {
// By Name -> Use Name // 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 // 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) // 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()) } is Sort.Mode.ByDate -> album.dates?.run { min.resolve(requireContext()) }
@ -115,7 +127,7 @@ class AlbumListFragment :
// Last added -> Format as date // Last added -> Format as date
is Sort.Mode.ByDateAdded -> { is Sort.Mode.ByDateAdded -> {
val dateAddedMillis = album.dateAdded.secsToMs() val dateAddedMillis = album.addedMs
formatterSb.setLength(0) formatterSb.setLength(0)
DateUtils.formatDateRange( DateUtils.formatDateRange(
context, context,
@ -147,6 +159,14 @@ class AlbumListFragment :
albumAdapter.update(albums, homeModel.albumInstructions.consume()) 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>) { private fun updateSelection(selection: List<Music>) {
albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) albumAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }

View file

@ -21,6 +21,8 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
@ -34,15 +36,16 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.ArtistViewHolder import org.oxycblt.auxio.list.recycler.ArtistViewHolder
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.positiveOrNull 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. * A [ListFragment] that shows a list of [Artist]s.
@ -74,7 +77,16 @@ class ArtistListFragment :
listener = this@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.artistList, ::updateArtists)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) 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. // Change how we display the popup depending on the current sort mode.
return when (homeModel.artistSort.mode) { return when (homeModel.artistSort.mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> artist.name.thumb is Sort.Mode.ByName -> artist.name.thumb()
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false) is Sort.Mode.ByDuration -> artist.durationMs?.formatDurationMs(false)
@ -123,6 +135,14 @@ class ArtistListFragment :
artistAdapter.update(artists, homeModel.artistInstructions.consume()) 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>) { private fun updateSelection(selection: List<Music>) {
artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) artistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }

View file

@ -21,6 +21,8 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
@ -34,14 +36,15 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.GenreViewHolder import org.oxycblt.auxio.list.recycler.GenreViewHolder
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately 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. * A [ListFragment] that shows a list of [Genre]s.
@ -73,7 +76,16 @@ class GenreListFragment :
listener = this@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.genreList, ::updateGenres)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) 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. // Change how we display the popup depending on the current sort mode.
return when (homeModel.genreSort.mode) { return when (homeModel.genreSort.mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> genre.name.thumb is Sort.Mode.ByName -> genre.name.thumb()
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> genre.durationMs.formatDurationMs(false)
@ -122,6 +134,14 @@ class GenreListFragment :
genreAdapter.update(genres, homeModel.genreInstructions.consume()) 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>) { private fun updateSelection(selection: List<Music>) {
genreAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) 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,6 +21,8 @@ package org.oxycblt.auxio.home.list
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentHomeListBinding import org.oxycblt.auxio.databinding.FragmentHomeListBinding
@ -33,14 +35,15 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.PlaylistViewHolder import org.oxycblt.auxio.list.recycler.PlaylistViewHolder
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel 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.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.util.collectImmediately 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. * A [ListFragment] that shows a list of [Playlist]s.
@ -71,7 +74,18 @@ class PlaylistListFragment :
listener = this@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.playlistList, ::updatePlaylists)
collectImmediately(
homeModel.empty,
homeModel.playlistList,
musicModel.indexingState,
::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) 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. // Change how we display the popup depending on the current sort mode.
return when (homeModel.playlistSort.mode) { return when (homeModel.playlistSort.mode) {
// By Name -> Use Name // By Name -> Use Name
is Sort.Mode.ByName -> playlist.name.thumb is Sort.Mode.ByName -> playlist.name.thumb()
// Duration -> Use formatted duration // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> playlist.durationMs.formatDurationMs(false)
@ -120,6 +134,26 @@ class PlaylistListFragment :
playlistAdapter.update(playlists, homeModel.playlistInstructions.consume()) 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>) { private fun updateSelection(selection: List<Music>) {
playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) playlistAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }

View file

@ -22,6 +22,8 @@ import android.os.Bundle
import android.text.format.DateUtils import android.text.format.DateUtils
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.util.Formatter import java.util.Formatter
@ -35,14 +37,15 @@ import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView import org.oxycblt.auxio.list.recycler.FastScrollRecyclerView
import org.oxycblt.auxio.list.recycler.SongViewHolder import org.oxycblt.auxio.list.recycler.SongViewHolder
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.IndexingState
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicViewModel 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.PlaybackViewModel
import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.formatDurationMs
import org.oxycblt.auxio.playback.secsToMs
import org.oxycblt.auxio.util.collectImmediately 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. * A [ListFragment] that shows a list of [Song]s.
@ -59,6 +62,7 @@ class SongListFragment :
override val musicModel: MusicViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels()
override val playbackModel: PlaybackViewModel by activityViewModels() override val playbackModel: PlaybackViewModel by activityViewModels()
private val songAdapter = SongAdapter(this) private val songAdapter = SongAdapter(this)
// Save memory by re-using the same formatter and string builder when creating popup text // Save memory by re-using the same formatter and string builder when creating popup text
private val formatterSb = StringBuilder(64) private val formatterSb = StringBuilder(64)
private val formatter = Formatter(formatterSb) private val formatter = Formatter(formatterSb)
@ -76,7 +80,16 @@ class SongListFragment :
listener = this@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.songList, ::updateSongs)
collectImmediately(homeModel.empty, musicModel.indexingState, ::updateNoMusicIndicator)
collectImmediately(listModel.selected, ::updateSelection) collectImmediately(listModel.selected, ::updateSelection)
collectImmediately( collectImmediately(
playbackModel.song, playbackModel.parent, playbackModel.isPlaying, ::updatePlayback) 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. // based off the names of the parent objects and not the child objects.
return when (homeModel.songSort.mode) { return when (homeModel.songSort.mode) {
// Name -> Use name // Name -> Use name
is Sort.Mode.ByName -> song.name.thumb is Sort.Mode.ByName -> song.name.thumb()
// Artist -> Use name of first artist // 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 // Album -> Use Album Name
is Sort.Mode.ByAlbum -> song.album.name.thumb is Sort.Mode.ByAlbum -> song.album.name.thumb()
// Year -> Use Full Year // 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 // Duration -> Use formatted duration
is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false) is Sort.Mode.ByDuration -> song.durationMs.formatDurationMs(false)
// Last added -> Format as date // Last added -> Format as date
is Sort.Mode.ByDateAdded -> { is Sort.Mode.ByDateAdded -> {
val dateAddedMillis = song.dateAdded.secsToMs() val dateAddedMillis = song.addedMs
formatterSb.setLength(0) formatterSb.setLength(0)
DateUtils.formatDateRange( DateUtils.formatDateRange(
context, context,
@ -146,6 +159,14 @@ class SongListFragment :
songAdapter.update(songs, homeModel.songInstructions.consume()) 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>) { private fun updateSelection(selection: List<Music>) {
songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf())) songAdapter.setSelected(selection.filterIsInstanceTo(mutableSetOf()))
} }

View file

@ -1,60 +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 homeTab = tabs[position]
val icon =
when (homeTab) {
MusicType.SONGS -> R.drawable.ic_song_24
MusicType.ALBUMS -> R.drawable.ic_album_24
MusicType.ARTISTS -> R.drawable.ic_artist_24
MusicType.GENRES -> R.drawable.ic_genre_24
MusicType.PLAYLISTS -> R.drawable.ic_playlist_24
}
// Use expected sw* size thresholds when choosing a configuration.
when {
// On small screens, only display an icon.
width < 370 -> tab.setIcon(icon).setContentDescription(homeTab.nameRes)
// On large screens, display an icon and text.
width < 600 -> tab.setText(homeTab.nameRes)
// On medium-size screens, display text.
else -> tab.setIcon(icon).setText(homeTab.nameRes)
}
}
}

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

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

View file

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

@ -37,31 +37,35 @@ import androidx.annotation.DrawableRes
import androidx.annotation.Px import androidx.annotation.Px
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.isEmpty
import androidx.core.view.updateMarginsRelative import androidx.core.view.updateMarginsRelative
import androidx.core.widget.ImageViewCompat import androidx.core.widget.ImageViewCompat
import coil.ImageLoader import coil3.ImageLoader
import coil.request.ImageRequest import coil3.asImage
import coil.util.CoilUtils 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.R as MR
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.shape.ShapeAppearanceModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.Cover import org.oxycblt.auxio.image.coil.RoundedRectTransformation
import org.oxycblt.auxio.image.extractor.RoundedRectTransformation import org.oxycblt.auxio.image.coil.SquareCropTransformation
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.ui.MaterialFader import org.oxycblt.auxio.ui.MaterialFader
import org.oxycblt.auxio.ui.UISettings import org.oxycblt.auxio.ui.UISettings
import org.oxycblt.auxio.util.getAttrColorCompat import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getColorCompat import org.oxycblt.auxio.util.getColorCompat
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat import org.oxycblt.auxio.util.getDrawableCompat
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 * Auxio's extension of [ImageView] that enables cover art loading and playing indicator and
@ -169,7 +173,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
super.onFinishInflate() super.onFinishInflate()
// The image isn't added if other children have populated the body. This is by design. // The image isn't added if other children have populated the body. This is by design.
if (childCount == 0) { if (isEmpty()) {
addView(image) addView(image)
} }
@ -313,7 +317,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(song: Song) = fun bind(song: Song) =
bindImpl( bindImpl(
listOf(song.cover), song.cover,
context.getString(R.string.desc_album_cover, song.album.name), context.getString(R.string.desc_album_cover, song.album.name),
R.drawable.ic_album_24) R.drawable.ic_album_24)
@ -324,7 +328,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(album: Album) = fun bind(album: Album) =
bindImpl( bindImpl(
album.cover.all, album.covers,
context.getString(R.string.desc_album_cover, album.name), context.getString(R.string.desc_album_cover, album.name),
R.drawable.ic_album_24) R.drawable.ic_album_24)
@ -335,7 +339,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(artist: Artist) = fun bind(artist: Artist) =
bindImpl( bindImpl(
artist.cover.all, artist.covers,
context.getString(R.string.desc_artist_image, artist.name), context.getString(R.string.desc_artist_image, artist.name),
R.drawable.ic_artist_24) R.drawable.ic_artist_24)
@ -346,7 +350,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(genre: Genre) = fun bind(genre: Genre) =
bindImpl( bindImpl(
genre.cover.all, genre.covers,
context.getString(R.string.desc_genre_image, genre.name), context.getString(R.string.desc_genre_image, genre.name),
R.drawable.ic_genre_24) R.drawable.ic_genre_24)
@ -357,7 +361,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(playlist: Playlist) = fun bind(playlist: Playlist) =
bindImpl( bindImpl(
playlist.cover?.all ?: emptyList(), playlist.covers,
context.getString(R.string.desc_playlist_image, playlist.name), context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24) R.drawable.ic_playlist_24)
@ -369,13 +373,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded. * @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) = 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 = val request =
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(covers) .data(cover)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)) .error(
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
.asImage())
.target(image) .target(image)
val cornersTransformation = val cornersTransformation =
@ -404,7 +410,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
@Px val iconSize: Int? @Px val iconSize: Int?
) : Drawable() { ) : Drawable() {
init { init {
// Re-tint the drawable to use the analogous "on surfaceg" color for // Re-tint the drawable to use the analogous "on surface" color for
// StyledImageView. // StyledImageView.
DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg)) DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg))
} }

View file

@ -49,7 +49,7 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
get() = get() =
CoverMode.fromIntCode( CoverMode.fromIntCode(
sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE)) sharedPreferences.getInt(getString(R.string.set_key_cover_mode), Int.MIN_VALUE))
?: CoverMode.MEDIA_STORE ?: CoverMode.BALANCED
override val forceSquareCovers: Boolean override val forceSquareCovers: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_square_covers), false) get() = sharedPreferences.getBoolean(getString(R.string.set_key_square_covers), false)
@ -64,8 +64,8 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
when { when {
!sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF !sharedPreferences.getBoolean(OLD_KEY_SHOW_COVERS, true) -> CoverMode.OFF
!sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) -> !sharedPreferences.getBoolean(OLD_KEY_QUALITY_COVERS, true) ->
CoverMode.MEDIA_STORE CoverMode.BALANCED
else -> CoverMode.QUALITY else -> CoverMode.BALANCED
} }
sharedPreferences.edit { sharedPreferences.edit {
@ -74,6 +74,24 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
remove(OLD_KEY_QUALITY_COVERS) 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) { override fun onSettingChanged(key: String, listener: ImageSettings.Listener) {
@ -87,5 +105,6 @@ class ImageSettingsImpl @Inject constructor(@ApplicationContext context: Context
private companion object { private companion object {
const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS" const val OLD_KEY_SHOW_COVERS = "KEY_SHOW_COVERS"
const val OLD_KEY_QUALITY_COVERS = "KEY_QUALITY_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 * 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 * 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 * 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/>. * 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 android.content.Context
import coil.ImageLoader import coil3.ImageLoader
import coil.request.CachePolicy import coil3.request.CachePolicy
import coil3.request.transitionFactory
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -30,19 +31,22 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class ExtractorModule { class CoilModule {
@Singleton @Singleton
@Provides @Provides
fun imageLoader( fun imageLoader(
@ApplicationContext context: Context, @ApplicationContext context: Context,
keyer: CoverKeyer, coverKeyer: CoverKeyer,
factory: CoverFetcher.Factory coverFactory: CoverFetcher.Factory,
coverCollectionKeyer: CoverCollectionKeyer,
coverCollectionFactory: CoverCollectionFetcher.Factory
) = ) =
ImageLoader.Builder(context) ImageLoader.Builder(context)
.components { .components {
// Add fetchers for Music components to make them usable with ImageRequest add(coverKeyer)
add(keyer) add(coverFactory)
add(factory) add(coverCollectionKeyer)
add(coverCollectionFactory)
} }
// Use our own crossfade with error drawable support // Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory()) .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/>. * 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 coil3.decode.DataSource
import coil.drawable.CrossfadeDrawable import coil3.request.ImageResult
import coil.request.ImageResult import coil3.request.SuccessResult
import coil.request.SuccessResult import coil3.transition.CrossfadeDrawable
import coil.transition.CrossfadeTransition import coil3.transition.CrossfadeTransition
import coil.transition.Transition import coil3.transition.Transition
import coil.transition.TransitionTarget import coil3.transition.TransitionTarget
/** /**
* A copy of [CrossfadeTransition.Factory] that also applies a transition to error results. * 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/>. * 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
import android.graphics.Bitmap.createBitmap import android.graphics.Bitmap.createBitmap
@ -30,16 +30,16 @@ import android.graphics.RectF
import android.graphics.Shader import android.graphics.Shader
import androidx.annotation.Px import androidx.annotation.Px
import androidx.core.graphics.applyCanvas import androidx.core.graphics.applyCanvas
import coil.decode.DecodeUtils import coil3.decode.DecodeUtils
import coil.size.Scale import coil3.size.Scale
import coil.size.Size import coil3.size.Size
import coil.size.pxOrElse import coil3.size.pxOrElse
import coil.transform.Transformation import coil3.transform.Transformation
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
* A vendoring of [coil.transform.RoundedCornersTransformation] that can handle non-1:1 aspect ratio * A vendoring of coil's RoundedCornersTransformation that can handle non-1:1 aspect ratio images
* images without cropping them. * without cropping them.
* *
* @author Coil Team, Alexander Capehart (OxygenCobalt) * @author Coil Team, Alexander Capehart (OxygenCobalt)
*/ */
@ -48,7 +48,7 @@ class RoundedRectTransformation(
@Px private val topRight: Float = 0f, @Px private val topRight: Float = 0f,
@Px private val bottomLeft: Float = 0f, @Px private val bottomLeft: Float = 0f,
@Px private val bottomRight: Float = 0f @Px private val bottomRight: Float = 0f
) : Transformation { ) : Transformation() {
constructor(@Px radius: Float) : this(radius, radius, radius, radius) constructor(@Px radius: Float) : this(radius, radius, radius, radius)

View file

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

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * 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 * 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 * 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/>. * 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.Binds
import dagger.Module import dagger.Module
@ -25,6 +25,6 @@ import dagger.hilt.components.SingletonComponent
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface DeviceModule { interface CoilModule {
@Binds fun deviceLibraryFactory(factory: DeviceLibraryFactoryImpl): DeviceLibrary.Factory @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,253 +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.annotation.SuppressLint
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 timber.log.Timber as L
/**
* 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 the first four loaded album covers. 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) {
L.e("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)
}
@SuppressLint("Recycle")
private suspend fun extractMediaStoreCover(cover: Cover) =
// Eliminate any chance that this blocking call might mess up the loading process
withContext(Dispatchers.IO) {
// Coil will recycle this InputStream, so we don't need to worry about it.
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,9 +22,9 @@ import androidx.annotation.StringRes
// TODO: Consider breaking this up into sealed classes for individual adapters // 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. */ /** A marker for something that is a RecyclerView item. Has no functionality on it's own. */
interface Item typealias Item = Any
interface Header : Item interface Header
/** /**
* A "header" used for delimiting groups of data. * A "header" used for delimiting groups of data.
@ -44,7 +44,7 @@ interface PlainHeader : Header {
*/ */
data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader data class BasicHeader(@StringRes override val titleRes: Int) : PlainHeader
interface Divider<T> : Item { interface Divider<T> {
val anchor: T? val anchor: T?
} }

View file

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

View file

@ -25,17 +25,17 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.list.menu.Menu 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.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
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 import timber.log.Timber as L
/** /**
@ -64,18 +64,17 @@ constructor(private val listSettings: ListSettings, private val musicRepository:
} }
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
val deviceLibrary = musicRepository.deviceLibrary ?: return val library = musicRepository.library ?: return
val userLibrary = musicRepository.userLibrary ?: return
// Sanitize the selection to remove items that no longer exist and thus // Sanitize the selection to remove items that no longer exist and thus
// won't appear in any list. // won't appear in any list.
_selected.value = _selected.value =
_selected.value.mapNotNull { _selected.value.mapNotNull {
when (it) { when (it) {
is Song -> deviceLibrary.findSong(it.uid) is Song -> library.findSong(it.uid)
is Album -> deviceLibrary.findAlbum(it.uid) is Album -> library.findAlbum(it.uid)
is Artist -> deviceLibrary.findArtist(it.uid) is Artist -> library.findArtist(it.uid)
is Genre -> deviceLibrary.findGenre(it.uid) is Genre -> library.findGenre(it.uid)
is Playlist -> userLibrary.findPlaylist(it.uid) is Playlist -> library.findPlaylist(it.uid)
} }
} }
} }

View file

@ -21,7 +21,7 @@ package org.oxycblt.auxio.list.adapter
import android.view.View import android.view.View
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.music.Music import org.oxycblt.musikr.Music
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -21,13 +21,13 @@ package org.oxycblt.auxio.list.menu
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.MenuRes import androidx.annotation.MenuRes
import kotlinx.parcelize.Parcelize 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.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. * Command to navigate to a specific menu dialog configuration.

View file

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

View file

@ -23,9 +23,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.music.MusicParent
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.playback.PlaySong import org.oxycblt.auxio.playback.PlaySong
import org.oxycblt.musikr.MusicParent
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -70,35 +70,35 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi
} }
private fun unpackSongParcel(parcel: Menu.ForSong.Parcel): Menu.ForSong? { 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 parent = parcel.playWithUid?.let(musicRepository::find) as MusicParent?
val playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null val playWith = PlaySong.fromIntCode(parcel.playWithCode, parent) ?: return null
return Menu.ForSong(parcel.res, song, playWith) return Menu.ForSong(parcel.res, song, playWith)
} }
private fun unpackAlbumParcel(parcel: Menu.ForAlbum.Parcel): Menu.ForAlbum? { 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) return Menu.ForAlbum(parcel.res, album)
} }
private fun unpackArtistParcel(parcel: Menu.ForArtist.Parcel): Menu.ForArtist? { 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) return Menu.ForArtist(parcel.res, artist)
} }
private fun unpackGenreParcel(parcel: Menu.ForGenre.Parcel): Menu.ForGenre? { 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) return Menu.ForGenre(parcel.res, genre)
} }
private fun unpackPlaylistParcel(parcel: Menu.ForPlaylist.Parcel): Menu.ForPlaylist? { 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) return Menu.ForPlaylist(parcel.res, playlist)
} }
private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? { private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? {
val deviceLibrary = musicRepository.deviceLibrary ?: return null val library = musicRepository.library ?: return null
val songs = parcel.songUids.mapNotNull(deviceLibrary::findSong) val songs = parcel.songUids.mapNotNull(library::findSong)
return Menu.ForSelection(parcel.res, songs) return Menu.ForSelection(parcel.res, songs)
} }
} }

View file

@ -19,21 +19,37 @@
package org.oxycblt.auxio.list.recycler package org.oxycblt.auxio.list.recycler
import android.animation.Animator import android.animation.Animator
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Rect import android.graphics.Rect
import android.os.Build
import android.text.TextUtils
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration import android.view.ViewConfiguration
import android.view.ViewGroup
import android.view.WindowInsets import android.view.WindowInsets
import android.widget.FrameLayout
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.core.view.isEmpty
import androidx.core.view.isInvisible
import androidx.core.view.updatePaddingRelative
import androidx.core.widget.TextViewCompat
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textview.MaterialTextView
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt import kotlin.math.roundToInt
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.MaterialFadingSlider
import org.oxycblt.auxio.ui.MaterialSlider import org.oxycblt.auxio.ui.MaterialSlider
import org.oxycblt.auxio.util.getAttrColorCompat
import org.oxycblt.auxio.util.getDimenPixels import org.oxycblt.auxio.util.getDimenPixels
import org.oxycblt.auxio.util.getDrawableCompat
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.isRtl import org.oxycblt.auxio.util.isRtl
import org.oxycblt.auxio.util.isUnder import org.oxycblt.auxio.util.isUnder
@ -62,33 +78,70 @@ import org.oxycblt.auxio.util.systemBarInsetsCompat
* - Added drag listener * - Added drag listener
* - Added documentation * - Added documentation
* - Completely new design * - Completely new design
* - New scroll position backend
* *
* @author Hai Zhang, Alexander Capehart (OxygenCobalt) * @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 class FastScrollRecyclerView
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioRecyclerView(context, attrs, defStyleAttr) { AuxioRecyclerView(context, attrs, defStyleAttr) {
// Thumb // Thumb
private val thumbSize = context.getDimenPixels(R.dimen.size_touchable_small) private val thumbWidth = context.getDimenPixels(R.dimen.spacing_mid_medium)
private val slider = MaterialSlider(context, thumbSize) private val thumbHeight = context.getDimenPixels(R.dimen.size_touchable_medium)
private val thumbSlider = MaterialSlider.small(context, thumbWidth)
private var thumbAnimator: Animator? = null private var thumbAnimator: Animator? = null
@SuppressLint("InflateParams")
private val thumbView = private val thumbView =
context.inflater.inflate(R.layout.view_scroll_thumb, null).apply { slider.jumpOut(this) } context.inflater.inflate(R.layout.view_scroll_thumb, null).apply {
thumbSlider.jumpOut(this)
}
private val thumbPadding = Rect(0, 0, 0, 0) private val thumbPadding = Rect(0, 0, 0, 0)
private var thumbOffset = 0 private var thumbOffset = 0
private var showingThumb = false private var showingThumb = false
private val hideThumbRunnable = Runnable { private val hideThumbRunnable = Runnable {
if (!dragging) { if (!dragging) {
hideScrollbar() hideThumb()
} }
} }
private val popupView =
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
}
}
private val popupSlider =
MaterialFadingSlider(MaterialSlider.large(context, popupView.minimumWidth / 2)).apply {
jumpOut(popupView)
}
private var popupAnimator: Animator? = null
private var showingPopup = false
// Touch // Touch
private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small) private val minTouchTargetSize = context.getDimenPixels(R.dimen.size_touchable_small)
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
@ -99,6 +152,24 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private var dragStartY = 0f private var dragStartY = 0f
private var dragStartThumbOffset = 0 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 private var dragging = false
set(value) { set(value) {
if (field == value) { if (field == value) {
@ -116,7 +187,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (field) { if (field) {
removeCallbacks(hideThumbRunnable) removeCallbacks(hideThumbRunnable)
showScrollbar() showScrollbar()
showPopup()
} else { } else {
hidePopup()
postAutoHideScrollbar() postAutoHideScrollbar()
} }
@ -128,6 +201,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
init { init {
overlay.add(thumbView) overlay.add(thumbView)
overlay.add(popupView)
addItemDecoration( addItemDecoration(
object : ItemDecoration() { object : ItemDecoration() {
@ -156,26 +230,96 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
// --- RECYCLERVIEW EVENT MANAGEMENT --- // --- RECYCLERVIEW EVENT MANAGEMENT ---
private fun onPreDraw() { private fun onPreDraw() {
updateScrollbarState() updateThumbState()
thumbView.layoutDirection = layoutDirection thumbView.layoutDirection = layoutDirection
thumbView.measure( thumbView.measure(
MeasureSpec.makeMeasureSpec(thumbSize, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(thumbWidth, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(thumbSize, MeasureSpec.EXACTLY)) MeasureSpec.makeMeasureSpec(thumbHeight, MeasureSpec.EXACTLY))
val thumbTop = thumbPadding.top + thumbOffset val thumbTop = thumbPadding.top + thumbOffset
val thumbLeft = val thumbLeft =
if (isRtl) { if (isRtl) {
thumbPadding.left thumbPadding.left
} else { } else {
width - thumbPadding.right - thumbSize width - thumbPadding.right - thumbWidth
} }
thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbSize, thumbTop + thumbSize) thumbView.layout(thumbLeft, thumbTop, thumbLeft + thumbWidth, thumbTop + thumbHeight)
popupView.layoutDirection = layoutDirection
val child = getChildAt(0)
val firstAdapterPos =
if (child != null) {
layoutManager?.getPosition(child) ?: NO_POSITION
} else {
NO_POSITION
}
val popupText: String
val provider = popupProvider
if (firstAdapterPos != NO_POSITION && provider != null) {
popupView.isInvisible = false
// Get the popup text. If there is none, we default to "?".
popupText = provider.getPopup(firstAdapterPos) ?: "?"
} else {
// No valid position or provider, do not show the popup.
popupView.isInvisible = false
popupText = ""
}
val popupLayoutParams = popupView.layoutParams as FrameLayout.LayoutParams
if (popupView.text != popupText) {
popupView.text = popupText
val widthMeasureSpec =
ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
thumbPadding.left +
thumbPadding.right +
thumbWidth +
popupLayoutParams.leftMargin +
popupLayoutParams.rightMargin,
popupLayoutParams.width)
val heightMeasureSpec =
ViewGroup.getChildMeasureSpec(
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
thumbPadding.top +
thumbPadding.bottom +
popupLayoutParams.topMargin +
popupLayoutParams.bottomMargin,
popupLayoutParams.height)
popupView.measure(widthMeasureSpec, heightMeasureSpec)
if (showingPopup) {
doPopupVibration()
}
}
val popupWidth = popupView.measuredWidth
val popupHeight = popupView.measuredHeight
val popupLeft =
if (layoutDirection == View.LAYOUT_DIRECTION_RTL) {
thumbPadding.left + thumbWidth + popupLayoutParams.leftMargin
} else {
width - thumbPadding.right - thumbWidth - popupLayoutParams.rightMargin - popupWidth
}
val popupAnchorY = popupHeight / 2
val thumbAnchorY = thumbView.height / 2
val popupTop =
(thumbTop + thumbAnchorY - popupAnchorY)
.coerceAtLeast(thumbPadding.top + popupLayoutParams.topMargin)
.coerceAtMost(
height - thumbPadding.bottom - popupLayoutParams.bottomMargin - popupHeight)
popupView.layout(popupLeft, popupTop, popupLeft + popupWidth, popupTop + popupHeight)
} }
override fun onScrolled(dx: Int, dy: Int) { override fun onScrolled(dx: Int, dy: Int) {
super.onScrolled(dx, dy) super.onScrolled(dx, dy)
updateScrollbarState() updateThumbState()
// Measure or layout events result in a fake onScrolled call. Ignore those. // Measure or layout events result in a fake onScrolled call. Ignore those.
if (dx == 0 && dy == 0) { if (dx == 0 && dy == 0) {
@ -193,11 +337,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
return insets return insets
} }
private fun updateScrollbarState() { private fun updateThumbState() {
// Then calculate the thumb position, which is just: // Then calculate the thumb position, which is just:
// [proportion of scroll position to scroll range] * [total thumb range] // [proportion of scroll position to scroll range] * [total thumb range]
// This is somewhat adapted from the androidx RecyclerView FastScroller implementation.
val offsetY = computeVerticalScrollOffset() val offsetY = computeVerticalScrollOffset()
if (computeVerticalScrollRange() < height || childCount == 0) { if (computeVerticalScrollRange() < height || isEmpty()) {
fastScrollingPossible = false
hideThumb()
hidePopup()
return return
} }
val extentY = computeVerticalScrollExtent() val extentY = computeVerticalScrollExtent()
@ -206,6 +354,10 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
private fun onItemTouch(event: MotionEvent): Boolean { private fun onItemTouch(event: MotionEvent): Boolean {
if (!fastScrollingEnabled || !fastScrollingPossible) {
dragging = false
return false
}
val eventX = event.x val eventX = event.x
val eventY = event.y val eventY = event.y
@ -219,8 +371,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) { if (thumbView.isUnder(eventX, eventY, minTouchTargetSize)) {
dragStartThumbOffset = thumbOffset dragStartThumbOffset = thumbOffset
} else if (eventX > thumbView.right - thumbSize / 4) { } else if (eventX > thumbView.right - thumbWidth / 4) {
dragStartThumbOffset = (eventY - thumbPadding.top - thumbSize / 2f).toInt() dragStartThumbOffset =
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset) scrollToThumbOffset(dragStartThumbOffset)
} else { } else {
return false return false
@ -238,7 +391,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
dragStartThumbOffset = thumbOffset dragStartThumbOffset = thumbOffset
} else { } else {
dragStartY = eventY dragStartY = eventY
dragStartThumbOffset = (eventY - thumbPadding.top - thumbSize / 2f).toInt() dragStartThumbOffset =
(eventY - thumbPadding.top - thumbHeight / 2f).toInt()
scrollToThumbOffset(dragStartThumbOffset) scrollToThumbOffset(dragStartThumbOffset)
} }
@ -282,30 +436,65 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
} }
private fun showScrollbar() { private fun showScrollbar() {
if (!fastScrollingEnabled || !fastScrollingPossible) {
return
}
if (showingThumb) { if (showingThumb) {
return return
} }
showingThumb = true showingThumb = true
thumbAnimator?.cancel() thumbAnimator?.cancel()
thumbAnimator = slider.slideIn(thumbView).also { it.start() } thumbAnimator = thumbSlider.slideIn(thumbView).also { it.start() }
} }
private fun hideScrollbar() { private fun hideThumb() {
if (!showingThumb) { if (!showingThumb) {
return return
} }
showingThumb = false showingThumb = false
thumbAnimator?.cancel() thumbAnimator?.cancel()
thumbAnimator = slider.slideOut(thumbView).also { it.start() } thumbAnimator = thumbSlider.slideOut(thumbView).also { it.start() }
}
private fun showPopup() {
if (!fastScrollingEnabled || !fastScrollingPossible) {
return
}
if (showingPopup) {
return
}
showingPopup = true
popupAnimator?.cancel()
popupAnimator = popupSlider.slideIn(popupView).also { it.start() }
}
private fun hidePopup() {
if (!showingPopup) {
return
}
showingPopup = false
popupAnimator?.cancel()
popupAnimator = popupSlider.slideOut(popupView).also { it.start() }
}
private fun doPopupVibration() {
performHapticFeedback(
if (Build.VERSION.SDK_INT >= 27) {
HapticFeedbackConstants.TEXT_HANDLE_MOVE
} else {
HapticFeedbackConstants.KEYBOARD_TAP
})
} }
// --- LAYOUT STATE --- // --- LAYOUT STATE ---
private val thumbOffsetRange: Int private val thumbOffsetRange: Int
get() { get() {
return height - thumbPadding.top - thumbPadding.bottom - thumbSize return height - thumbPadding.top - thumbPadding.bottom - thumbHeight
} }
/** An interface to provide text to use in the popup when fast-scrolling. */ /** An interface to provide text to use in the popup when fast-scrolling. */

View file

@ -92,7 +92,6 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() {
// Hook drag events to "lifting" the item (i.e raising it's elevation). Make sure // 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. // 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) { if (shouldLift && isCurrentlyActive && actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
L.d("Lifting ViewHolder") L.d("Lifting ViewHolder")

View file

@ -32,16 +32,17 @@ import org.oxycblt.auxio.list.PlainDivider
import org.oxycblt.auxio.list.SelectableListListener import org.oxycblt.auxio.list.SelectableListListener
import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback 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.areNamesTheSame
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.music.resolveNames
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.getPlural
import org.oxycblt.auxio.util.inflater 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. * A [RecyclerView.ViewHolder] that displays a [Song]. Use [from] to create an instance.

View file

@ -20,11 +20,11 @@ package org.oxycblt.auxio.list.sort
import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.Album import org.oxycblt.musikr.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.musikr.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.musikr.Genre
import org.oxycblt.auxio.music.Playlist import org.oxycblt.musikr.Playlist
import org.oxycblt.auxio.music.Song import org.oxycblt.musikr.Song
/** /**
* A sorting method. * A sorting method.
@ -360,16 +360,16 @@ data class Sort(val mode: Mode, val direction: Direction) {
override fun sortSongs(songs: MutableList<Song>, direction: Direction) { override fun sortSongs(songs: MutableList<Song>, direction: Direction) {
songs.sortBy { it.name } songs.sortBy { it.name }
when (direction) { when (direction) {
Direction.ASCENDING -> songs.sortBy { it.dateAdded } Direction.ASCENDING -> songs.sortBy { it.addedMs }
Direction.DESCENDING -> songs.sortByDescending { it.dateAdded } Direction.DESCENDING -> songs.sortByDescending { it.addedMs }
} }
} }
override fun sortAlbums(albums: MutableList<Album>, direction: Direction) { override fun sortAlbums(albums: MutableList<Album>, direction: Direction) {
albums.sortBy { it.name } albums.sortBy { it.name }
when (direction) { when (direction) {
Direction.ASCENDING -> albums.sortBy { it.dateAdded } Direction.ASCENDING -> albums.sortBy { it.addedMs }
Direction.DESCENDING -> albums.sortByDescending { it.dateAdded } Direction.DESCENDING -> albums.sortByDescending { it.addedMs }
} }
} }
} }

View file

@ -1,87 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* Indexing.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.music
import android.os.Build
/** Version-aware permission identifier for reading audio files. */
val PERMISSION_READ_AUDIO =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.Manifest.permission.READ_MEDIA_AUDIO
} else {
android.Manifest.permission.READ_EXTERNAL_STORAGE
}
/**
* Represents the current state of the music loader.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface IndexingState {
/**
* Music loading is on-going.
*
* @param progress The current progress of the music loading.
*/
data class Indexing(val progress: IndexingProgress) : IndexingState
/**
* Music loading has completed.
*
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
* will be null.
*/
data class Completed(val error: Exception?) : IndexingState
}
/**
* Represents the current progress of music loading.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface IndexingProgress {
/** Other work is being done that does not have a defined progress. */
data object Indeterminate : IndexingProgress
/**
* Songs are currently being loaded.
*
* @param current The current amount of songs loaded.
* @param total The projected total amount of songs.
*/
data class Songs(val current: Int, val total: Int) : IndexingProgress
}
/**
* Thrown by the music loader when [PERMISSION_READ_AUDIO] was not granted.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class NoAudioPermissionException : Exception() {
override val message = "Storage permissions are required to load music"
}
/**
* Thrown when no music was found.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class NoMusicException : Exception() {
override val message = "No music was found on the device"
}

View file

@ -19,31 +19,30 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import dagger.hilt.android.qualifiers.ApplicationContext
import androidx.core.content.ContextCompat import java.util.UUID
import java.util.LinkedList
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.oxycblt.auxio.music.cache.CacheRepository import org.oxycblt.auxio.image.covers.SettingCovers
import org.oxycblt.auxio.music.device.DeviceLibrary import org.oxycblt.auxio.music.MusicRepository.IndexingWorker
import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.shim.WriteOnlyMutableCache
import org.oxycblt.auxio.music.fs.MediaStoreExtractor import org.oxycblt.musikr.IndexingProgress
import org.oxycblt.auxio.music.info.Name import org.oxycblt.musikr.Interpretation
import org.oxycblt.auxio.music.metadata.Separators import org.oxycblt.musikr.Library
import org.oxycblt.auxio.music.metadata.TagExtractor import org.oxycblt.musikr.Music
import org.oxycblt.auxio.music.user.MutableUserLibrary import org.oxycblt.musikr.Musikr
import org.oxycblt.auxio.music.user.UserLibrary import org.oxycblt.musikr.MutableLibrary
import org.oxycblt.auxio.util.DEFAULT_TIMEOUT import org.oxycblt.musikr.Playlist
import org.oxycblt.auxio.util.forEachWithTimeout import org.oxycblt.musikr.Song
import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.cache.MutableCache
import org.oxycblt.musikr.playlist.db.StoredPlaylists
import org.oxycblt.musikr.tag.interpret.Naming
import org.oxycblt.musikr.tag.interpret.Separators
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -58,10 +57,9 @@ import timber.log.Timber as L
* configurations * configurations
*/ */
interface MusicRepository { interface MusicRepository {
/** The current music information found on the device. */ /** The current library */
val deviceLibrary: DeviceLibrary? val library: Library?
/** The current user-defined music information. */
val userLibrary: UserLibrary?
/** The current state of music loading. Null if no load has occurred yet. */ /** The current state of music loading. Null if no load has occurred yet. */
val indexingState: IndexingState? val indexingState: IndexingState?
@ -175,7 +173,7 @@ interface MusicRepository {
* @param withCache Whether to load with the music cache or not. * @param withCache Whether to load with the music cache or not.
* @return The top-level music loading [Job] started. * @return The top-level music loading [Job] started.
*/ */
fun index(worker: IndexingWorker, withCache: Boolean): Job suspend fun index(worker: IndexingWorker, withCache: Boolean)
/** A listener for changes to the stored music information. */ /** A listener for changes to the stored music information. */
interface UpdateListener { interface UpdateListener {
@ -190,8 +188,8 @@ interface MusicRepository {
/** /**
* Flags indicating which kinds of music information changed. * Flags indicating which kinds of music information changed.
* *
* @param deviceLibrary Whether the current [DeviceLibrary] has changed. * @param deviceLibrary Whether the current songs/albums/artists/genres has changed.
* @param userLibrary Whether the current [Playlist]s have changed. * @param userLibrary Whether the current playlists have changed.
*/ */
data class Changes(val deviceLibrary: Boolean, val userLibrary: Boolean) data class Changes(val deviceLibrary: Boolean, val userLibrary: Boolean)
@ -203,12 +201,6 @@ interface MusicRepository {
/** A persistent worker that can load music in the background. */ /** A persistent worker that can load music in the background. */
interface IndexingWorker { interface IndexingWorker {
/** A [Context] required to read device storage */
val workerContext: Context
/** The [CoroutineScope] to perform coroutine music loading work on. */
val scope: CoroutineScope
/** /**
* Request that the music loading process ([index]) should be started. Any prior loads * Request that the music loading process ([index]) should be started. Any prior loads
* should be canceled. * should be canceled.
@ -219,22 +211,42 @@ interface MusicRepository {
} }
} }
/**
* Represents the current state of the music loader.
*
* @author Alexander Capehart (OxygenCobalt)
*/
sealed interface IndexingState {
/**
* Music loading is on-going.
*
* @param progress The current progress of the music loading.
*/
data class Indexing(val progress: IndexingProgress) : IndexingState
/**
* Music loading has completed.
*
* @param error If music loading has failed, the error that occurred will be here. Otherwise, it
* will be null.
*/
data class Completed(val error: Exception?) : IndexingState
}
class MusicRepositoryImpl class MusicRepositoryImpl
@Inject @Inject
constructor( constructor(
private val cacheRepository: CacheRepository, @ApplicationContext private val context: Context,
private val mediaStoreExtractor: MediaStoreExtractor, private val cache: MutableCache,
private val tagExtractor: TagExtractor, private val storedPlaylists: StoredPlaylists,
private val deviceLibraryFactory: DeviceLibrary.Factory, private val settingCovers: SettingCovers,
private val userLibraryFactory: UserLibrary.Factory,
private val musicSettings: MusicSettings private val musicSettings: MusicSettings
) : MusicRepository { ) : MusicRepository {
private val updateListeners = mutableListOf<MusicRepository.UpdateListener>() private val updateListeners = mutableListOf<MusicRepository.UpdateListener>()
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>() private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
@Volatile private var indexingWorker: MusicRepository.IndexingWorker? = null @Volatile private var indexingWorker: IndexingWorker? = null
@Volatile override var deviceLibrary: DeviceLibrary? = null @Volatile override var library: MutableLibrary? = null
@Volatile override var userLibrary: MutableUserLibrary? = null
@Volatile private var previousCompletedState: IndexingState.Completed? = null @Volatile private var previousCompletedState: IndexingState.Completed? = null
@Volatile private var currentIndexingState: IndexingState? = null @Volatile private var currentIndexingState: IndexingState? = null
override val indexingState: IndexingState? override val indexingState: IndexingState?
@ -271,7 +283,7 @@ constructor(
} }
@Synchronized @Synchronized
override fun registerWorker(worker: MusicRepository.IndexingWorker) { override fun registerWorker(worker: IndexingWorker) {
if (indexingWorker != null) { if (indexingWorker != null) {
L.w("Worker is already registered") L.w("Worker is already registered")
return return
@ -281,7 +293,7 @@ constructor(
} }
@Synchronized @Synchronized
override fun unregisterWorker(worker: MusicRepository.IndexingWorker) { override fun unregisterWorker(worker: IndexingWorker) {
if (indexingWorker !== worker) { if (indexingWorker !== worker) {
L.w("Given worker did not match current worker") L.w("Given worker did not match current worker")
return return
@ -293,41 +305,51 @@ constructor(
@Synchronized @Synchronized
override fun find(uid: Music.UID) = override fun find(uid: Music.UID) =
(deviceLibrary?.run { findSong(uid) ?: findAlbum(uid) ?: findArtist(uid) ?: findGenre(uid) } (library?.run {
?: userLibrary?.findPlaylist(uid)) findSong(uid)
?: findAlbum(uid)
?: findArtist(uid)
?: findGenre(uid)
?: findPlaylist(uid)
})
override suspend fun createPlaylist(name: String, songs: List<Song>) { override suspend fun createPlaylist(name: String, songs: List<Song>) {
val userLibrary = synchronized(this) { userLibrary ?: return } val library = synchronized(this) { library ?: return }
L.d("Creating playlist $name with ${songs.size} songs") L.d("Creating playlist $name with ${songs.size} songs")
userLibrary.createPlaylist(name, songs) val newLibrary = library.createPlaylist(name, songs)
synchronized(this) { this.library = newLibrary }
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
} }
override suspend fun renamePlaylist(playlist: Playlist, name: String) { override suspend fun renamePlaylist(playlist: Playlist, name: String) {
val userLibrary = synchronized(this) { userLibrary ?: return } val library = synchronized(this) { library ?: return }
L.d("Renaming $playlist to $name") L.d("Renaming $playlist to $name")
userLibrary.renamePlaylist(playlist, name) val newLibrary = library.renamePlaylist(playlist, name)
synchronized(this) { this.library = newLibrary }
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
} }
override suspend fun deletePlaylist(playlist: Playlist) { override suspend fun deletePlaylist(playlist: Playlist) {
val userLibrary = synchronized(this) { userLibrary ?: return } val library = synchronized(this) { library ?: return }
L.d("Deleting $playlist") L.d("Deleting $playlist")
userLibrary.deletePlaylist(playlist) val newLibrary = library.deletePlaylist(playlist)
synchronized(this) { this.library = newLibrary }
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
} }
override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) { override suspend fun addToPlaylist(songs: List<Song>, playlist: Playlist) {
val userLibrary = synchronized(this) { userLibrary ?: return } val library = synchronized(this) { library ?: return }
L.d("Adding ${songs.size} songs to $playlist") L.d("Adding ${songs.size} songs to $playlist")
userLibrary.addToPlaylist(playlist, songs) val newLibrary = library.addToPlaylist(playlist, songs)
synchronized(this) { this.library = newLibrary }
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
} }
override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) { override suspend fun rewritePlaylist(playlist: Playlist, songs: List<Song>) {
val userLibrary = synchronized(this) { userLibrary ?: return } val library = synchronized(this) { library ?: return }
L.d("Rewriting $playlist with ${songs.size} songs") L.d("Rewriting $playlist with ${songs.size} songs")
userLibrary.rewritePlaylist(playlist, songs) val newLibrary = library.rewritePlaylist(playlist, songs)
synchronized(this) { this.library = newLibrary }
withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) } withContext(Dispatchers.Main) { dispatchLibraryChange(device = false, user = true) }
} }
@ -337,241 +359,53 @@ constructor(
indexingWorker?.requestIndex(withCache) indexingWorker?.requestIndex(withCache)
} }
override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) = override suspend fun index(worker: IndexingWorker, withCache: Boolean) {
worker.scope.launch { indexWrapper(worker.workerContext, this, withCache) } L.d("Begin index [cache=$withCache]")
private suspend fun indexWrapper(context: Context, scope: CoroutineScope, withCache: Boolean) {
try { try {
indexImpl(context, scope, withCache) indexImpl(withCache)
} catch (e: CancellationException) { } catch (e: CancellationException) {
// Got cancelled, propagate upwards to top-level co-routine. // Got cancelled, propagate upwards to top-level co-routine.
L.d("Loading routine was cancelled") L.d("Loading routine was cancelled")
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
// Music loading process failed due to something we have not handled. // Music loading process failed due to something we have not handled.
// TODO: Still want to display this error eventually
L.e("Music indexing failed") L.e("Music indexing failed")
L.e(e.stackTraceToString()) L.e(e.stackTraceToString())
emitIndexingCompletion(e) emitIndexingCompletion(e)
} }
} }
private suspend fun indexImpl(context: Context, scope: CoroutineScope, withCache: Boolean) { private suspend fun indexImpl(withCache: Boolean) {
// TODO: Find a way to break up this monster of a method, preferably as another class.
val start = System.currentTimeMillis()
// Make sure we have permissions before going forward. Theoretically this would be better
// done at the UI level, but that intertwines logic and display too much.
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED) {
L.e("Permissions were not granted")
throw NoAudioPermissionException()
}
// Obtain configuration information // Obtain configuration information
val constraints =
MediaStoreExtractor.Constraints(musicSettings.excludeNonMusic, musicSettings.musicDirs)
val separators = Separators.from(musicSettings.separators) val separators = Separators.from(musicSettings.separators)
val nameFactory = val nameFactory =
if (musicSettings.intelligentSorting) { if (musicSettings.intelligentSorting) {
Name.Known.IntelligentFactory Naming.intelligent()
} else { } else {
Name.Known.SimpleFactory Naming.simple()
} }
val locations = musicSettings.musicLocations
val withHidden = musicSettings.withHidden
// Begin with querying MediaStore and the music cache. The former is needed for Auxio val currentRevision = musicSettings.revision
// to figure out what songs are (probably) on the device, and the latter will be needed val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID()
// for discovery (described later). These have no shared state, so they are done in val cache = if (withCache) cache else WriteOnlyMutableCache(cache)
// parallel. val covers = settingCovers.mutate(context, newRevision)
L.d("Starting MediaStore query") val storage = Storage(cache, covers, storedPlaylists)
emitIndexingProgress(IndexingProgress.Indeterminate) val interpretation = Interpretation(nameFactory, separators, withHidden)
val result =
val mediaStoreQueryJob = Musikr.new(context, storage, interpretation).run(locations, ::emitIndexingProgress)
scope.async { // Music loading completed, update the revision right now so we re-use this work
val query = // later.
try { musicSettings.revision = newRevision
mediaStoreExtractor.query(constraints) // Deliver the library to the rest of the app
} catch (e: Exception) { // This will more or less block until all required item translation and
// Normally, errors in an async call immediately bubble up to the Looper // cleanup finishes.
// and crash the app. Thus, we have to wrap any error into a Result emitLibrary(result.library)
// and then manually forward it to the try block that indexImpl is // Clean up old data that is now impossible for the app to be using.
// called from. result.cleanup()
return@async Result.failure(e) // Finish up loading.
}
Result.success(query)
}
// Since this main thread is a co-routine, we can do operations in parallel in a way
// identical to calling async.
val cache =
if (withCache) {
L.d("Reading cache")
cacheRepository.readCache()
} else {
null
}
L.d("Awaiting MediaStore query")
val query = mediaStoreQueryJob.await().getOrThrow()
// We now have all the information required to start the "discovery" process. This
// is the point at which Auxio starts scanning each file given from MediaStore and
// transforming it into a music library. MediaStore normally
L.d("Starting discovery")
val incompleteSongs = Channel<RawSong>(Channel.UNLIMITED) // Not fully populated w/metadata
val completeSongs = Channel<RawSong>(Channel.UNLIMITED) // Populated with quality metadata
val processedSongs = Channel<RawSong>(Channel.UNLIMITED) // Transformed into SongImpl
// MediaStoreExtractor discovers all music on the device, and forwards them to either
// DeviceLibrary if cached metadata exists for it, or TagExtractor if cached metadata
// does not exist. In the latter situation, it also applies it's own (inferior) metadata.
L.d("Starting MediaStore discovery")
val mediaStoreJob =
scope.async {
try {
mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs)
} catch (e: Exception) {
// To prevent a deadlock, we want to close the channel with an exception
// to cascade to and cancel all other routines before finally bubbling up
// to the main extractor loop.
L.e("MediaStore extraction failed: $e")
incompleteSongs.close(
Exception("MediaStore extraction failed: ${e.stackTraceToString()}"))
return@async
}
incompleteSongs.close()
}
// TagExtractor takes the incomplete songs from MediaStoreExtractor, parses up-to-date
// metadata for them, and then forwards it to DeviceLibrary.
L.d("Starting tag extraction")
val tagJob =
scope.async {
try {
tagExtractor.consume(incompleteSongs, completeSongs)
} catch (e: Exception) {
L.e("Tag extraction failed: $e")
completeSongs.close(
Exception("Tag extraction failed: ${e.stackTraceToString()}"))
return@async
}
completeSongs.close()
}
// DeviceLibrary constructs music parent instances as song information is provided,
// and then forwards them to the primary loading loop.
L.d("Starting DeviceLibrary creation")
val deviceLibraryJob =
scope.async(Dispatchers.Default) {
val deviceLibrary =
try {
deviceLibraryFactory.create(
completeSongs, processedSongs, separators, nameFactory)
} catch (e: Exception) {
L.e("DeviceLibrary creation failed: $e")
processedSongs.close(
Exception("DeviceLibrary creation failed: ${e.stackTraceToString()}"))
return@async Result.failure(e)
}
processedSongs.close()
Result.success(deviceLibrary)
}
// We could keep track of a total here, but we also need to collate this RawSong information
// for when we write the cache later on in the finalization step.
val rawSongs = LinkedList<RawSong>()
// Use a longer timeout so that dependent components can timeout and throw errors that
// provide more context than if we timed out here.
processedSongs.forEachWithTimeout(DEFAULT_TIMEOUT * 2) {
rawSongs.add(it)
// Since discovery takes up the bulk of the music loading process, we switch to
// indicating a defined amount of loaded songs in comparison to the projected amount
// of songs that were queried.
emitIndexingProgress(IndexingProgress.Songs(rawSongs.size, query.projectedTotal))
}
withTimeout(DEFAULT_TIMEOUT) {
mediaStoreJob.await()
tagJob.await()
}
// Deliberately done after the involved initialization step to make it less likely
// that the short-circuit occurs so quickly as to break the UI.
// TODO: Do not error, instead just wipe the entire library.
if (rawSongs.isEmpty()) {
L.e("Music library was empty")
throw NoMusicException()
}
// Now that the library is effectively loaded, we can start the finalization step, which
// involves writing new cache information and creating more music data that is derived
// from the library (e.g playlists)
L.d("Discovered ${rawSongs.size} songs, starting finalization")
// We have no idea how long the cache will take, and the playlist construction
// will be too fast to indicate, so switch back to an indeterminate state.
emitIndexingProgress(IndexingProgress.Indeterminate)
// The UserLibrary job is split into a query and construction step, a la MediaStore.
// This way, we can start working on playlists even as DeviceLibrary might still be
// working on parent information.
L.d("Starting UserLibrary query")
val userLibraryQueryJob =
scope.async {
val rawPlaylists =
try {
userLibraryFactory.query()
} catch (e: Exception) {
return@async Result.failure(e)
}
Result.success(rawPlaylists)
}
// The cache might not exist, or we might have encountered a song not present in it.
// Both situations require us to rewrite the cache in bulk. This is also done parallel
// since the playlist read will probably take some time.
// TODO: Read/write from the cache incrementally instead of in bulk?
if (cache == null || cache.invalidated) {
L.d("Writing cache [why=${cache?.invalidated}]")
cacheRepository.writeCache(rawSongs)
}
// Create UserLibrary once we finally get the required components for it.
L.d("Awaiting UserLibrary query")
val rawPlaylists = userLibraryQueryJob.await().getOrThrow()
L.d("Awaiting DeviceLibrary creation")
val deviceLibrary = deviceLibraryJob.await().getOrThrow()
L.d("Starting UserLibrary creation")
val userLibrary = userLibraryFactory.create(rawPlaylists, deviceLibrary, nameFactory)
// Loading process is functionally done, indicate such
L.d(
"Successfully indexed music library [device=$deviceLibrary " +
"user=$userLibrary time=${System.currentTimeMillis() - start}]")
emitIndexingCompletion(null) emitIndexingCompletion(null)
val deviceLibraryChanged: Boolean
val userLibraryChanged: Boolean
// We want to make sure that all reads and writes are synchronized due to the sheer
// amount of consumers of MusicRepository.
// TODO: Would Atomics not be a better fit here?
synchronized(this) {
// It's possible that this reload might have changed nothing, so make sure that
// hasn't happened before dispatching a change to all consumers.
deviceLibraryChanged = this.deviceLibrary != deviceLibrary
userLibraryChanged = this.userLibrary != userLibrary
if (!deviceLibraryChanged && !userLibraryChanged) {
L.d("Library has not changed, skipping update")
return
}
this.deviceLibrary = deviceLibrary
this.userLibrary = userLibrary
}
// Consumers expect their updates to be on the main thread (notably PlaybackService),
// so switch to it.
withContext(Dispatchers.Main) {
dispatchLibraryChange(deviceLibraryChanged, userLibraryChanged)
}
} }
private suspend fun emitIndexingProgress(progress: IndexingProgress) { private suspend fun emitIndexingProgress(progress: IndexingProgress) {
@ -584,6 +418,39 @@ constructor(
} }
} }
private suspend fun emitLibrary(newLibrary: MutableLibrary) {
val deviceLibraryChanged: Boolean
val userLibraryChanged: Boolean
// We want to make sure that all reads and writes are synchronized due to the sheer
// amount of consumers of MusicRepository.
synchronized(this) {
// It's possible that this reload might have changed nothing, so make sure that
// hasn't happened before dispatching a change to all consumers.
// This is an old compat shim back when device library and user library were different
// thinks. For the sake of avoiding drastic changes, it sticks around.
// TODO: Remove this once you start work on kindred.
deviceLibraryChanged =
this.library?.songs != newLibrary.songs ||
this.library?.albums != newLibrary.albums ||
this.library?.artists != newLibrary.artists ||
this.library?.genres != newLibrary.genres
userLibraryChanged = this.library?.playlists != newLibrary.playlists
if (!deviceLibraryChanged && !userLibraryChanged) {
L.d("Library has not changed, skipping update")
return
}
this.library = newLibrary
}
// Consumers expect their updates to be on the main thread (notably PlaybackService),
// so switch to it.
withContext(Dispatchers.Main) {
dispatchLibraryChange(deviceLibraryChanged, userLibraryChanged)
}
}
private suspend fun emitIndexingCompletion(error: Exception?) { private suspend fun emitIndexingCompletion(error: Exception?) {
yield() yield()
synchronized(this) { synchronized(this) {

View file

@ -21,11 +21,11 @@ package org.oxycblt.auxio.music
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.dirs.MusicDirectories
import org.oxycblt.auxio.music.fs.DocumentPathFactory
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.musikr.fs.MusicLocation
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -34,10 +34,14 @@ import timber.log.Timber as L
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
interface MusicSettings : Settings<MusicSettings.Listener> { interface MusicSettings : Settings<MusicSettings.Listener> {
/** The configuration on how to handle particular directories in the music library. */ /** The current library revision. */
var musicDirs: MusicDirectories var revision: UUID?
/** The locations of music to load. */
var musicLocations: List<MusicLocation>
/** Whether to exclude non-music audio files from the music library. */ /** Whether to exclude non-music audio files from the music library. */
val excludeNonMusic: Boolean val excludeNonMusic: Boolean
/** Whether to ignore hidden files and directories during music loading. */
val withHidden: Boolean
/** Whether to be actively watching for changes in the music library. */ /** Whether to be actively watching for changes in the music library. */
val shouldBeObserving: Boolean val shouldBeObserving: Boolean
/** A [String] of characters representing the desired characters to denote multi-value tags. */ /** A [String] of characters representing the desired characters to denote multi-value tags. */
@ -46,6 +50,8 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
val intelligentSorting: Boolean val intelligentSorting: Boolean
interface Listener { interface Listener {
/** Called when the current music locations changed. */
fun onMusicLocationsChanged() {}
/** Called when a setting controlling how music is loaded has changed. */ /** Called when a setting controlling how music is loaded has changed. */
fun onIndexingSettingChanged() {} fun onIndexingSettingChanged() {}
/** Called when the [shouldBeObserving] configuration has changed. */ /** Called when the [shouldBeObserving] configuration has changed. */
@ -53,35 +59,45 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
} }
} }
class MusicSettingsImpl class MusicSettingsImpl @Inject constructor(@ApplicationContext private val context: Context) :
@Inject Settings.Impl<MusicSettings.Listener>(context), MusicSettings {
constructor(
@ApplicationContext context: Context, override var revision: UUID?
private val documentPathFactory: DocumentPathFactory get() =
) : Settings.Impl<MusicSettings.Listener>(context), MusicSettings { sharedPreferences
override var musicDirs: MusicDirectories .getString(getString(R.string.set_key_library_revision), null)
?.let(UUID::fromString)
set(value) {
sharedPreferences.edit {
putString(getString(R.string.set_key_library_revision), value.toString())
apply()
}
}
override var musicLocations: List<MusicLocation>
get() { get() {
val dirs = val locations =
(sharedPreferences.getStringSet(getString(R.string.set_key_music_dirs), null) sharedPreferences.getString(getString(R.string.set_key_music_locations), null)
?: emptySet()) ?: return emptyList()
.mapNotNull(documentPathFactory::fromDocumentId) return MusicLocation.existing(context, locations)
return MusicDirectories(
dirs,
sharedPreferences.getBoolean(getString(R.string.set_key_music_dirs_include), false))
} }
set(value) { set(value) {
sharedPreferences.edit { sharedPreferences.edit {
putStringSet( putString(
getString(R.string.set_key_music_dirs), getString(R.string.set_key_music_locations), MusicLocation.toString(value))
value.dirs.map(documentPathFactory::toDocumentId).toSet()) commit()
putBoolean(getString(R.string.set_key_music_dirs_include), value.shouldInclude) // Sometimes changing this setting just won't actually trigger the listener.
apply() // Only this one. No idea why.
listener?.onMusicLocationsChanged()
} }
} }
override val excludeNonMusic: Boolean override val excludeNonMusic: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true) get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true)
override val withHidden: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_with_hidden), false)
override val shouldBeObserving: Boolean override val shouldBeObserving: Boolean
get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false) get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
@ -103,11 +119,14 @@ constructor(
// TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads" // TODO: Differentiate "hard reloads" (Need the cache) and "Soft reloads"
// (just need to manipulate data) // (just need to manipulate data)
when (key) { when (key) {
getString(R.string.set_key_exclude_non_music), getString(R.string.set_key_music_locations) -> {
getString(R.string.set_key_music_dirs), L.d("Dispatching music locations change")
getString(R.string.set_key_music_dirs_include), listener.onMusicLocationsChanged()
}
getString(R.string.set_key_separators), getString(R.string.set_key_separators),
getString(R.string.set_key_auto_sort_names) -> { getString(R.string.set_key_auto_sort_names),
getString(R.string.set_key_with_hidden),
getString(R.string.set_key_exclude_non_music) -> {
L.d("Dispatching indexing setting change for $key") L.d("Dispatching indexing setting change for $key")
listener.onIndexingSettingChanged() listener.onIndexingSettingChanged()
} }

View file

@ -27,15 +27,10 @@ import org.oxycblt.auxio.R
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
enum class MusicType { enum class MusicType {
/** @see Song */
SONGS, SONGS,
/** @see Album */
ALBUMS, ALBUMS,
/** @see Artist */
ARTISTS, ARTISTS,
/** @see Genre */
GENRES, GENRES,
/** @see Playlist */
PLAYLISTS; PLAYLISTS;
/** /**

View file

@ -0,0 +1,173 @@
/*
* Copyright (c) 2024 Auxio Project
* MusicUtil.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.music
import android.content.Context
import java.text.ParseException
import java.text.SimpleDateFormat
import kotlin.math.max
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.concatLocalized
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.fs.Format
import org.oxycblt.musikr.tag.Date
import org.oxycblt.musikr.tag.Disc
import org.oxycblt.musikr.tag.Name
import org.oxycblt.musikr.tag.Placeholder
import org.oxycblt.musikr.tag.ReleaseType
import org.oxycblt.musikr.tag.ReleaseType.Refinement
import timber.log.Timber
fun Name.resolve(context: Context) =
when (this) {
is Name.Known -> raw
is Name.Unknown ->
when (placeholder) {
Placeholder.ALBUM -> context.getString(R.string.def_album)
Placeholder.ARTIST -> context.getString(R.string.def_artist)
Placeholder.GENRE -> context.getString(R.string.def_genre)
}
}
/**
* Run [Name.resolve] on each instance in the given list and concatenate them into a [String] in a
* localized manner.
*
* @param context [Context] required
* @return A concatenated string.
*/
fun <T : Music> List<T>.resolveNames(context: Context) =
concatLocalized(context) { it.name.resolve(context) }
/**
* Returns if [Music.name] matches for each item in a list. Useful for scenarios where the display
* information of an item must be compared without a context.
*
* @param other The list of items to compare to.
* @return True if they are the same (by [Music.name]), false otherwise.
*/
fun <T : Music> List<T>.areNamesTheSame(other: List<T>): Boolean {
for (i in 0 until max(size, other.size)) {
val a = getOrNull(i) ?: return false
val b = other.getOrNull(i) ?: return false
if (a.name != b.name) {
return false
}
}
return true
}
/**
* Resolve this instance into a human-readable date.
*
* @param context [Context] required to get human-readable names.
* @return If the [Date] has a valid month and year value, a more fine-grained date (ex. "Jan 2020")
* will be returned. Otherwise, a plain year value (ex. "2020") is returned. Dates will be
* properly localized.
*/
fun Date.resolve(context: Context) =
// Unable to create fine-grained date, just format as a year.
month?.let { resolveFineGrained() } ?: context.getString(R.string.fmt_number, year)
private fun Date.resolveFineGrained(): String? {
// We can't directly load a date with our own
val format = (SimpleDateFormat.getDateInstance() as SimpleDateFormat)
format.applyPattern("yyyy-MM")
val date =
try {
format.parse("$year-$month")
} catch (e: ParseException) {
Timber.e("Unable to parse fine-grained date: $e")
return null
}
// Reformat as a readable month and year
format.applyPattern("MMM yyyy")
return format.format(date)
}
fun Disc?.resolve(context: Context) =
this?.run { context.getString(R.string.fmt_disc_no, number) }
?: context.getString(R.string.def_disc)
/**
* Resolve this instance into a human-readable date range.
*
* @param context [Context] required to get human-readable names.
* @return If the date has a maximum value, then a `min - max` formatted string will be returned
* with the formatted [Date]s of the minimum and maximum dates respectively. Otherwise, the
* formatted name of the minimum [Date] will be returned.
*/
fun Date.Range.resolve(context: Context) =
if (min != max) {
context.getString(R.string.fmt_date_range, min.resolve(context), max.resolve(context))
} else {
min.resolve(context)
}
fun ReleaseType.resolve(context: Context) =
when (this) {
is ReleaseType.Album ->
when (refinement) {
null -> context.getString(R.string.lbl_album)
Refinement.LIVE -> context.getString(R.string.lbl_album_live)
Refinement.REMIX -> context.getString(R.string.lbl_album_remix)
}
is ReleaseType.EP ->
when (refinement) {
null -> context.getString(R.string.lbl_ep)
Refinement.LIVE -> context.getString(R.string.lbl_ep_live)
Refinement.REMIX -> context.getString(R.string.lbl_ep_remix)
}
is ReleaseType.Single ->
when (refinement) {
null -> context.getString(R.string.lbl_single)
Refinement.LIVE -> context.getString(R.string.lbl_single_live)
Refinement.REMIX -> context.getString(R.string.lbl_single_remix)
}
is ReleaseType.Compilation ->
when (refinement) {
null -> context.getString(R.string.lbl_compilation)
Refinement.LIVE -> context.getString(R.string.lbl_compilation_live)
Refinement.REMIX -> context.getString(R.string.lbl_compilation_remix)
}
is ReleaseType.Soundtrack -> context.getString(R.string.lbl_soundtrack)
is ReleaseType.Mix -> context.getString(R.string.lbl_mix)
is ReleaseType.Mixtape -> context.getString(R.string.lbl_mixtape)
is ReleaseType.Demo -> context.getString(R.string.lbl_demo)
}
fun Format.resolve(context: Context): String =
when (this) {
is Format.MPEG3 -> context.getString(R.string.cdc_mp3)
is Format.MPEG4 ->
containing?.let { context.getString(R.string.cnt_mp4, it.resolve(context)) }
?: context.getString(R.string.cdc_mp4)
is Format.AAC -> context.getString(R.string.cdc_aac)
is Format.ALAC -> context.getString(R.string.cdc_alac)
is Format.Ogg ->
containing?.let { context.getString(R.string.cnt_ogg, it.resolve(context)) }
?: context.getString(R.string.cdc_ogg)
is Format.Opus -> context.getString(R.string.cdc_opus)
is Format.Vorbis -> context.getString(R.string.cdc_vorbis)
is Format.FLAC -> context.getString(R.string.cdc_flac)
is Format.Wav -> context.getString(R.string.cdc_wav)
is Format.Unknown -> extension ?: context.getString(R.string.cdc_unknown)
}

View file

@ -18,10 +18,12 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -29,10 +31,15 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.ListSettings
import org.oxycblt.auxio.music.external.ExportConfig
import org.oxycblt.auxio.music.external.ExternalPlaylistManager
import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.Event
import org.oxycblt.auxio.util.MutableEvent import org.oxycblt.auxio.util.MutableEvent
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.playlist.ExportConfig
import org.oxycblt.musikr.playlist.ExternalPlaylistManager
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -44,10 +51,11 @@ import timber.log.Timber as L
class MusicViewModel class MusicViewModel
@Inject @Inject
constructor( constructor(
@ApplicationContext context: Context,
private val listSettings: ListSettings, private val listSettings: ListSettings,
private val musicRepository: MusicRepository, private val musicRepository: MusicRepository
private val externalPlaylistManager: ExternalPlaylistManager
) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener { ) : ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener {
private val externalPlaylistManager = ExternalPlaylistManager.from(context)
private val _indexingState = MutableStateFlow<IndexingState?>(null) private val _indexingState = MutableStateFlow<IndexingState?>(null)
@ -85,14 +93,14 @@ constructor(
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
if (!changes.deviceLibrary) return if (!changes.deviceLibrary) return
val deviceLibrary = musicRepository.deviceLibrary ?: return val library = musicRepository.library ?: return
_statistics.value = _statistics.value =
Statistics( Statistics(
deviceLibrary.songs.size, library.songs.size,
deviceLibrary.albums.size, library.albums.size,
deviceLibrary.artists.size, library.artists.size,
deviceLibrary.genres.size, library.genres.size,
deviceLibrary.songs.sumOf { it.durationMs }) library.songs.sumOf { it.durationMs })
L.d("Updated statistics: ${_statistics.value}") L.d("Updated statistics: ${_statistics.value}")
} }
@ -162,10 +170,10 @@ constructor(
return@launch return@launch
} }
val deviceLibrary = musicRepository.deviceLibrary ?: return@launch val library = musicRepository.library ?: return@launch
val songs = val songs =
importedPlaylist.paths.mapNotNull { importedPlaylist.paths.mapNotNull {
it.firstNotNullOfOrNull(deviceLibrary::findSongByPath) it.firstNotNullOfOrNull(library::findSongByPath)
} }
if (songs.isEmpty()) { if (songs.isEmpty()) {

View file

@ -1,187 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* CacheDatabase.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.music.cache
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import org.oxycblt.auxio.music.device.RawSong
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.metadata.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped
@Database(entities = [CachedSong::class], version = 49, exportSchema = false)
abstract class CacheDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CachedSongsDao
}
@Dao
interface CachedSongsDao {
@Query("SELECT * FROM CachedSong") suspend fun readSongs(): List<CachedSong>
@Query("DELETE FROM CachedSong") suspend fun nukeSongs()
@Insert suspend fun insertSongs(songs: List<CachedSong>)
}
@Entity
@TypeConverters(CachedSong.Converters::class)
data class CachedSong(
/**
* The ID of the [RawSong]'s audio file, obtained from MediaStore. Note that this ID is highly
* unstable and should only be used for accessing the audio file.
*/
@PrimaryKey var mediaStoreId: Long,
/** @see RawSong.dateAdded */
var dateAdded: Long,
/** The latest date the [RawSong]'s audio file was modified, as a unix epoch timestamp. */
var dateModified: Long,
/** @see RawSong.size */
var size: Long? = null,
/** @see RawSong */
var durationMs: Long,
/** @see RawSong.replayGainTrackAdjustment */
val replayGainTrackAdjustment: Float? = null,
/** @see RawSong.replayGainAlbumAdjustment */
val replayGainAlbumAdjustment: Float? = null,
/** @see RawSong.musicBrainzId */
var musicBrainzId: String? = null,
/** @see RawSong.name */
var name: String,
/** @see RawSong.sortName */
var sortName: String? = null,
/** @see RawSong.track */
var track: Int? = null,
/** @see RawSong.name */
var disc: Int? = null,
/** @See RawSong.subtitle */
var subtitle: String? = null,
/** @see RawSong.date */
var date: Date? = null,
/** @see RawSong.coverPerceptualHash */
var coverPerceptualHash: String? = null,
/** @see RawSong.albumMusicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see RawSong.albumName */
var albumName: String,
/** @see RawSong.albumSortName */
var albumSortName: String? = null,
/** @see RawSong.releaseTypes */
var releaseTypes: List<String> = listOf(),
/** @see RawSong.artistMusicBrainzIds */
var artistMusicBrainzIds: List<String> = listOf(),
/** @see RawSong.artistNames */
var artistNames: List<String> = listOf(),
/** @see RawSong.artistSortNames */
var artistSortNames: List<String> = listOf(),
/** @see RawSong.albumArtistMusicBrainzIds */
var albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see RawSong.albumArtistNames */
var albumArtistNames: List<String> = listOf(),
/** @see RawSong.albumArtistSortNames */
var albumArtistSortNames: List<String> = listOf(),
/** @see RawSong.genreNames */
var genreNames: List<String> = listOf()
) {
fun copyToRaw(rawSong: RawSong) {
rawSong.musicBrainzId = musicBrainzId
rawSong.name = name
rawSong.sortName = sortName
rawSong.size = size
rawSong.durationMs = durationMs
rawSong.replayGainTrackAdjustment = replayGainTrackAdjustment
rawSong.replayGainAlbumAdjustment = replayGainAlbumAdjustment
rawSong.track = track
rawSong.disc = disc
rawSong.subtitle = subtitle
rawSong.date = date
rawSong.coverPerceptualHash = coverPerceptualHash
rawSong.albumMusicBrainzId = albumMusicBrainzId
rawSong.albumName = albumName
rawSong.albumSortName = albumSortName
rawSong.releaseTypes = releaseTypes
rawSong.artistMusicBrainzIds = artistMusicBrainzIds
rawSong.artistNames = artistNames
rawSong.artistSortNames = artistSortNames
rawSong.albumArtistMusicBrainzIds = albumArtistMusicBrainzIds
rawSong.albumArtistNames = albumArtistNames
rawSong.albumArtistSortNames = albumArtistSortNames
rawSong.genreNames = genreNames
}
object Converters {
@TypeConverter
fun fromMultiValue(values: List<String>) =
values.joinToString(";") { it.replace(";", "\\;") }
@TypeConverter
fun toMultiValue(string: String) = string.splitEscaped { it == ';' }.correctWhitespace()
@TypeConverter fun fromDate(date: Date?) = date?.toString()
@TypeConverter fun toDate(string: String?) = string?.let(Date::from)
}
companion object {
fun fromRaw(rawSong: RawSong) =
CachedSong(
mediaStoreId =
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No MediaStore ID" },
dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" },
dateModified =
requireNotNull(rawSong.dateModified) { "Invalid raw: No date modified" },
musicBrainzId = rawSong.musicBrainzId,
name = requireNotNull(rawSong.name) { "Invalid raw: No name" },
sortName = rawSong.sortName,
size = rawSong.size,
durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" },
replayGainTrackAdjustment = rawSong.replayGainTrackAdjustment,
replayGainAlbumAdjustment = rawSong.replayGainAlbumAdjustment,
track = rawSong.track,
disc = rawSong.disc,
subtitle = rawSong.subtitle,
date = rawSong.date,
coverPerceptualHash = rawSong.coverPerceptualHash,
albumMusicBrainzId = rawSong.albumMusicBrainzId,
albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" },
albumSortName = rawSong.albumSortName,
releaseTypes = rawSong.releaseTypes,
artistMusicBrainzIds = rawSong.artistMusicBrainzIds,
artistNames = rawSong.artistNames,
artistSortNames = rawSong.artistSortNames,
albumArtistMusicBrainzIds = rawSong.albumArtistMusicBrainzIds,
albumArtistNames = rawSong.albumArtistNames,
albumArtistSortNames = rawSong.albumArtistSortNames,
genreNames = rawSong.genreNames)
}
}

View file

@ -1,121 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* CacheRepository.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.music.cache
import javax.inject.Inject
import org.oxycblt.auxio.music.device.RawSong
import timber.log.Timber as L
/**
* A repository allowing access to cached metadata obtained in prior music loading operations.
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface CacheRepository {
/**
* Read the current [Cache], if it exists.
*
* @return The stored [Cache], or null if it could not be obtained.
*/
suspend fun readCache(): Cache?
/**
* Write the list of newly-loaded [RawSong]s to the cache, replacing the prior data.
*
* @param rawSongs The [rawSongs] to write to the cache.
*/
suspend fun writeCache(rawSongs: List<RawSong>)
}
class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: CachedSongsDao) :
CacheRepository {
override suspend fun readCache(): Cache? =
try {
// Faster to load the whole database into memory than do a query on each
// populate call.
val songs = cachedSongsDao.readSongs()
L.d("Successfully read ${songs.size} songs from cache")
CacheImpl(songs)
} catch (e: Exception) {
L.e("Unable to load cache database.")
L.e(e.stackTraceToString())
null
}
override suspend fun writeCache(rawSongs: List<RawSong>) {
try {
// Still write out whatever data was extracted.
cachedSongsDao.nukeSongs()
L.d("Successfully deleted old cache")
cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw))
L.d("Successfully wrote ${rawSongs.size} songs to cache")
} catch (e: Exception) {
L.e("Unable to save cache database.")
L.e(e.stackTraceToString())
}
}
}
/**
* A cache of music metadata obtained in prior music loading operations. Obtain an instance with
* [CacheRepository].
*
* @author Alexander Capehart (OxygenCobalt)
*/
interface Cache {
/** Whether this cache has encountered a [RawSong] that did not have a cache entry. */
val invalidated: Boolean
/**
* Populate a [RawSong] from a cache entry, if it exists.
*
* @param rawSong The [RawSong] to populate.
* @return true if a cache entry could be applied to [rawSong], false otherwise.
*/
fun populate(rawSong: RawSong): Boolean
}
private class CacheImpl(cachedSongs: List<CachedSong>) : Cache {
private val cacheMap = buildMap {
for (cachedSong in cachedSongs) {
put(cachedSong.mediaStoreId, cachedSong)
}
}
override var invalidated = false
override fun populate(rawSong: RawSong): Boolean {
// For a cached raw song to be used, it must exist within the cache and have matching
// addition and modification timestamps. Technically the addition timestamp doesn't
// exist, but to safeguard against possible OEM-specific timestamp incoherence, we
// check for it anyway.
val cachedSong = cacheMap[rawSong.mediaStoreId]
if (cachedSong != null &&
cachedSong.dateAdded == rawSong.dateAdded &&
cachedSong.dateModified == rawSong.dateModified) {
cachedSong.copyToRaw(rawSong)
return true
}
// We could not populate this song. This means our cache is stale and should be
// re-written with newly-loaded music.
invalidated = true
return false
}
}

View file

@ -33,10 +33,10 @@ import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding
import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.navigateSafe import org.oxycblt.auxio.util.navigateSafe
import org.oxycblt.musikr.Song
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -29,10 +29,11 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding import org.oxycblt.auxio.databinding.DialogDeletePlaylistBinding
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Playlist
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -52,6 +53,9 @@ class DeletePlaylistDialog : ViewBindingMaterialDialogFragment<DialogDeletePlayl
builder builder
.setTitle(R.string.lbl_confirm_delete_playlist) .setTitle(R.string.lbl_confirm_delete_playlist)
.setPositiveButton(R.string.lbl_delete) { _, _ -> .setPositiveButton(R.string.lbl_delete) { _, _ ->
// Normally the navigateUp will occur after this, which then collides with the
// playlist view's navigation. Forcefully navigate up to stop this.
findNavController().navigateUp()
// Now we can delete the playlist for-real this time. // Now we can delete the playlist for-real this time.
musicModel.deletePlaylist( musicModel.deletePlaylist(
unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true) unlikelyToBeNull(pickerModel.currentPlaylistToDelete.value), rude = true)

View file

@ -31,12 +31,13 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistExportBinding import org.oxycblt.auxio.databinding.DialogPlaylistExportBinding
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.external.ExportConfig
import org.oxycblt.auxio.music.external.M3U
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.playlist.ExportConfig
import org.oxycblt.musikr.playlist.m3u.M3U
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -25,6 +25,7 @@ import org.oxycblt.auxio.list.ClickableListListener
import org.oxycblt.auxio.list.adapter.FlexibleListAdapter import org.oxycblt.auxio.list.adapter.FlexibleListAdapter
import org.oxycblt.auxio.list.adapter.SimpleDiffCallback import org.oxycblt.auxio.list.adapter.SimpleDiffCallback
import org.oxycblt.auxio.list.recycler.DialogRecyclerView import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater

View file

@ -25,14 +25,14 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.PlaylistDecision
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.music.external.ExportConfig import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.playlist.ExportConfig
import timber.log.Timber as L import timber.log.Timber as L
/** /**
@ -89,13 +89,13 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
override fun onMusicChanges(changes: MusicRepository.Changes) { override fun onMusicChanges(changes: MusicRepository.Changes) {
var refreshChoicesWith: List<Song>? = null var refreshChoicesWith: List<Song>? = null
val deviceLibrary = musicRepository.deviceLibrary val library = musicRepository.library
if (changes.deviceLibrary && deviceLibrary != null) { if (changes.deviceLibrary && library != null) {
_currentPendingNewPlaylist.value = _currentPendingNewPlaylist.value =
_currentPendingNewPlaylist.value?.let { pendingPlaylist -> _currentPendingNewPlaylist.value?.let { pendingPlaylist ->
PendingNewPlaylist( PendingNewPlaylist(
pendingPlaylist.preferredName, pendingPlaylist.preferredName,
pendingPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.uid) }, pendingPlaylist.songs.mapNotNull { library.findSong(it.uid) },
pendingPlaylist.template, pendingPlaylist.template,
pendingPlaylist.reason) pendingPlaylist.reason)
} }
@ -104,7 +104,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
_currentSongsToAdd.value = _currentSongsToAdd.value =
_currentSongsToAdd.value?.let { pendingSongs -> _currentSongsToAdd.value?.let { pendingSongs ->
pendingSongs pendingSongs
.mapNotNull { deviceLibrary.findSong(it.uid) } .mapNotNull { library.findSong(it.uid) }
.ifEmpty { null } .ifEmpty { null }
.also { refreshChoicesWith = it } .also { refreshChoicesWith = it }
} }
@ -127,7 +127,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
_currentPlaylistToExport.value = _currentPlaylistToExport.value =
_currentPlaylistToExport.value?.let { playlist -> _currentPlaylistToExport.value?.let { playlist ->
musicRepository.userLibrary?.findPlaylist(playlist.uid) musicRepository.library?.findPlaylist(playlist.uid)
} }
L.d("Updated playlist to export to ${_currentPlaylistToExport.value}") L.d("Updated playlist to export to ${_currentPlaylistToExport.value}")
} }
@ -153,14 +153,14 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
reason: PlaylistDecision.New.Reason reason: PlaylistDecision.New.Reason
) { ) {
L.d("Opening ${songUids.size} songs to create a playlist from") L.d("Opening ${songUids.size} songs to create a playlist from")
val userLibrary = musicRepository.userLibrary ?: return val library = musicRepository.library ?: return
val songs = val songs =
musicRepository.deviceLibrary musicRepository.library
?.let { songUids.mapNotNull(it::findSong) } ?.let { songUids.mapNotNull(it::findSong) }
?.also(::refreshPlaylistChoices) ?.also(::refreshPlaylistChoices)
val possibleName = val possibleName =
musicRepository.userLibrary?.let { musicRepository.library?.let {
// Attempt to generate a unique default name for the playlist, like "Playlist 1". // Attempt to generate a unique default name for the playlist, like "Playlist 1".
var i = 1 var i = 1
var possibleName: String var possibleName: String
@ -168,7 +168,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
possibleName = context.getString(R.string.fmt_def_playlist, i) possibleName = context.getString(R.string.fmt_def_playlist, i)
L.d("Trying $possibleName as a playlist name") L.d("Trying $possibleName as a playlist name")
++i ++i
} while (userLibrary.playlists.any { it.name.resolve(context) == possibleName }) } while (library.playlists.any { it.name.resolve(context) == possibleName })
L.d("$possibleName is unique, using it as the playlist name") L.d("$possibleName is unique, using it as the playlist name")
possibleName possibleName
} }
@ -194,9 +194,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
reason: PlaylistDecision.Rename.Reason reason: PlaylistDecision.Rename.Reason
) { ) {
L.d("Opening playlist $playlistUid to rename") L.d("Opening playlist $playlistUid to rename")
val playlist = musicRepository.userLibrary?.findPlaylist(playlistUid) val playlist = musicRepository.library?.findPlaylist(playlistUid)
val applySongs = val applySongs = musicRepository.library?.let { applySongUids.mapNotNull(it::findSong) }
musicRepository.deviceLibrary?.let { applySongUids.mapNotNull(it::findSong) }
_currentPendingRenamePlaylist.value = _currentPendingRenamePlaylist.value =
if (playlist != null && applySongs != null) { if (playlist != null && applySongs != null) {
@ -216,7 +215,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
L.d("Opening playlist $playlistUid to export") L.d("Opening playlist $playlistUid to export")
// TODO: Add this guard to the rest of the methods here // TODO: Add this guard to the rest of the methods here
if (_currentPlaylistToExport.value?.uid == playlistUid) return if (_currentPlaylistToExport.value?.uid == playlistUid) return
_currentPlaylistToExport.value = musicRepository.userLibrary?.findPlaylist(playlistUid) _currentPlaylistToExport.value = musicRepository.library?.findPlaylist(playlistUid)
if (_currentPlaylistToExport.value == null) { if (_currentPlaylistToExport.value == null) {
L.w("Given playlist UID to export was invalid") L.w("Given playlist UID to export was invalid")
} else { } else {
@ -241,7 +240,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
*/ */
fun setPlaylistToDelete(playlistUid: Music.UID) { fun setPlaylistToDelete(playlistUid: Music.UID) {
L.d("Opening playlist $playlistUid to delete") L.d("Opening playlist $playlistUid to delete")
_currentPlaylistToDelete.value = musicRepository.userLibrary?.findPlaylist(playlistUid) _currentPlaylistToDelete.value = musicRepository.library?.findPlaylist(playlistUid)
if (_currentPlaylistToDelete.value == null) { if (_currentPlaylistToDelete.value == null) {
L.w("Given playlist UID to delete was invalid") L.w("Given playlist UID to delete was invalid")
} }
@ -266,8 +265,8 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
} }
else -> { else -> {
val trimmed = name.trim() val trimmed = name.trim()
val userLibrary = musicRepository.userLibrary val library = musicRepository.library
if (userLibrary != null && userLibrary.findPlaylist(trimmed) == null) { if (library != null && library.findPlaylistByName(trimmed) == null) {
L.d("Chosen name is valid") L.d("Chosen name is valid")
ChosenName.Valid(trimmed) ChosenName.Valid(trimmed)
} else { } else {
@ -286,7 +285,7 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
fun setSongsToAdd(songUids: Array<Music.UID>) { fun setSongsToAdd(songUids: Array<Music.UID>) {
L.d("Opening ${songUids.size} songs to add to a playlist") L.d("Opening ${songUids.size} songs to add to a playlist")
_currentSongsToAdd.value = _currentSongsToAdd.value =
musicRepository.deviceLibrary musicRepository.library
?.let { songUids.mapNotNull(it::findSong).ifEmpty { null } } ?.let { songUids.mapNotNull(it::findSong).ifEmpty { null } }
?.also(::refreshPlaylistChoices) ?.also(::refreshPlaylistChoices)
if (_currentSongsToAdd.value == null || songUids.size != _currentSongsToAdd.value?.size) { if (_currentSongsToAdd.value == null || songUids.size != _currentSongsToAdd.value?.size) {
@ -295,10 +294,10 @@ class PlaylistPickerViewModel @Inject constructor(private val musicRepository: M
} }
private fun refreshPlaylistChoices(songs: List<Song>) { private fun refreshPlaylistChoices(songs: List<Song>) {
val userLibrary = musicRepository.userLibrary ?: return val library = musicRepository.library ?: return
L.d("Refreshing playlist choices") L.d("Refreshing playlist choices")
_playlistAddChoices.value = _playlistAddChoices.value =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(userLibrary.playlists).map { Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING).playlists(library.playlists).map {
val songSet = it.songs.toSet() val songSet = it.songs.toSet()
PlaylistChoice(it, songs.all(songSet::contains)) PlaylistChoice(it, songs.all(songSet::contains))
} }
@ -355,4 +354,4 @@ sealed interface ChosenName {
* [Playlist]. * [Playlist].
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean) : Item data class PlaylistChoice(val playlist: Playlist, val alreadyAdded: Boolean)

View file

@ -30,6 +30,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding import org.oxycblt.auxio.databinding.DialogPlaylistNameBinding
import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.resolve
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull

View file

@ -1,383 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* DeviceLibrary.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.music.device
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.channels.Channel
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.Song
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.fs.contentResolverSafe
import org.oxycblt.auxio.music.fs.useQuery
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.util.forEachWithTimeout
import org.oxycblt.auxio.util.sendWithTimeout
import org.oxycblt.auxio.util.unlikelyToBeNull
import timber.log.Timber as L
/**
* Organized music library information obtained from device storage.
*
* This class allows for the creation of a well-formed music library graph from raw song
* information. Instances are immutable. It's generally not expected to create this yourself and
* instead use [MusicRepository].
*
* @author Alexander Capehart
*/
interface DeviceLibrary {
/** All [Song]s in this [DeviceLibrary]. */
val songs: Collection<Song>
/** All [Album]s in this [DeviceLibrary]. */
val albums: Collection<Album>
/** All [Artist]s in this [DeviceLibrary]. */
val artists: Collection<Artist>
/** All [Genre]s in this [DeviceLibrary]. */
val genres: Collection<Genre>
/**
* Find a [Song] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Song], or null if one was not found.
*/
fun findSong(uid: Music.UID): Song?
/**
* Find a [Song] instance corresponding to the given Intent.ACTION_VIEW [Uri].
*
* @param context [Context] required to analyze the [Uri].
* @param uri [Uri] to search for.
* @return A [Song] corresponding to the given [Uri], or null if one could not be found.
*/
fun findSongForUri(context: Context, uri: Uri): Song?
/**
* Find a [Song] instance corresponding to the given [Path].
*
* @param path [Path] to search for.
* @return A [Song] corresponding to the given [Path], or null if one could not be found.
*/
fun findSongByPath(path: Path): Song?
/**
* Find a [Album] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Album], or null if one was not found.
*/
fun findAlbum(uid: Music.UID): Album?
/**
* Find a [Artist] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Artist], or null if one was not found.
*/
fun findArtist(uid: Music.UID): Artist?
/**
* Find a [Genre] instance corresponding to the given [Music.UID].
*
* @param uid The [Music.UID] to search for.
* @return The corresponding [Genre], or null if one was not found.
*/
fun findGenre(uid: Music.UID): Genre?
/** Constructs a [DeviceLibrary] implementation in an asynchronous manner. */
interface Factory {
/**
* Creates a new [DeviceLibrary] instance asynchronously based on the incoming stream of
* [RawSong] instances.
*
* @param rawSongs A stream of [RawSong] instances to process.
* @param processedSongs A stream of [RawSong] instances that will have been processed by
* the instance.
*/
suspend fun create(
rawSongs: Channel<RawSong>,
processedSongs: Channel<RawSong>,
separators: Separators,
nameFactory: Name.Known.Factory
): DeviceLibraryImpl
}
}
class DeviceLibraryFactoryImpl @Inject constructor() : DeviceLibrary.Factory {
override suspend fun create(
rawSongs: Channel<RawSong>,
processedSongs: Channel<RawSong>,
separators: Separators,
nameFactory: Name.Known.Factory
): DeviceLibraryImpl {
val songGrouping = mutableMapOf<Music.UID, SongImpl>()
val albumGrouping = mutableMapOf<String?, MutableMap<UUID?, Grouping<RawAlbum, SongImpl>>>()
val artistGrouping = mutableMapOf<String?, MutableMap<UUID?, Grouping<RawArtist, Music>>>()
val genreGrouping = mutableMapOf<String?, Grouping<RawGenre, SongImpl>>()
// All music information is grouped as it is indexed by other components.
rawSongs.forEachWithTimeout { rawSong ->
val song = SongImpl(rawSong, nameFactory, separators)
// At times the indexer produces duplicate songs, try to filter these. Comparing by
// UID is sufficient for something like this, and also prevents collisions from
// causing severe issues elsewhere.
if (songGrouping.containsKey(song.uid)) {
L.w(
"Duplicate song found: ${song.path} " +
"collides with ${unlikelyToBeNull(songGrouping[song.uid]).path}")
// We still want to say that we "processed" the song so that the user doesn't
// get confused at why the bar was only partly filled by the end of the loading
// process.
processedSongs.sendWithTimeout(rawSong)
return@forEachWithTimeout
}
songGrouping[song.uid] = song
// Group the new song into an album.
appendToMusicBrainzIdTree(song, song.rawAlbum, albumGrouping) { old, new ->
compareSongTracks(old, new)
}
// Group the song into each of it's artists.
for (rawArtist in song.rawArtists) {
appendToMusicBrainzIdTree(song, rawArtist, artistGrouping) { old, new ->
// Artist information from earlier dates is prioritized, as it is less likely to
// change with the addition of new tracks. Fall back to the name otherwise.
check(old is SongImpl) // This should always be the case.
compareSongDates(old, new)
}
}
// Group the song into each of it's genres.
for (rawGenre in song.rawGenres) {
appendToNameTree(song, rawGenre, genreGrouping) { old, new -> new.name < old.name }
}
processedSongs.sendWithTimeout(rawSong)
}
// Now that all songs are processed, also process albums and group them into their
// respective artists.
pruneMusicBrainzIdTree(albumGrouping) { old, new ->
compareSongTracks(old, new)
}
val albums = flattenMusicBrainzIdTree(albumGrouping) { AlbumImpl(it, nameFactory) }
for (album in albums) {
for (rawArtist in album.rawArtists) {
appendToMusicBrainzIdTree(album, rawArtist, artistGrouping) { old, new ->
when (old) {
// Immediately replace any songs that initially held the priority position.
is SongImpl -> true
is AlbumImpl -> {
compareAlbumDates(old, new)
}
else -> throw IllegalStateException()
}
}
}
}
// Artists and genres do not need to be grouped and can be processed immediately.
pruneMusicBrainzIdTree(artistGrouping) { old, new ->
when {
// Immediately replace any songs that initially held the priority position.
old is SongImpl && new is AlbumImpl -> true
old is AlbumImpl && new is SongImpl -> false
old is SongImpl && new is SongImpl -> {
compareSongDates(old, new)
}
old is AlbumImpl && new is AlbumImpl -> {
compareAlbumDates(old, new)
}
else -> throw IllegalStateException()
}
}
val artists = flattenMusicBrainzIdTree(artistGrouping) { ArtistImpl(it, nameFactory) }
val genres = flattenNameTree(genreGrouping) { GenreImpl(it, nameFactory) }
return DeviceLibraryImpl(songGrouping.values.toSet(), albums, artists, genres)
}
private inline fun <R : NameGroupable, O : Music, N : O> appendToNameTree(
music: N,
raw: R,
tree: MutableMap<String?, Grouping<R, O>>,
prioritize: (old: O, new: N) -> Boolean,
) {
val nameKey = raw.name?.lowercase()
val body = tree[nameKey]
if (body != null) {
body.music.add(music)
if (prioritize(body.raw.src, music)) {
body.raw = PrioritizedRaw(raw, music)
}
} else {
// Need to initialize this grouping.
tree[nameKey] = Grouping(PrioritizedRaw(raw, music), mutableSetOf(music))
}
}
private inline fun <R : NameGroupable, O : Music, P : MusicParent> flattenNameTree(
tree: MutableMap<String?, Grouping<R, O>>,
map: (Grouping<R, O>) -> P
): Set<P> = tree.values.mapTo(mutableSetOf()) { map(it) }
private inline fun <R : MusicBrainzGroupable, O : Music, N : O> appendToMusicBrainzIdTree(
music: N,
raw: R,
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, O>>>,
prioritize: (old: O, new: N) -> Boolean,
) {
val nameKey = raw.name?.lowercase()
val musicBrainzIdGroups = tree[nameKey]
if (musicBrainzIdGroups != null) {
val body = musicBrainzIdGroups[raw.musicBrainzId]
if (body != null) {
body.music.add(music)
if (prioritize(body.raw.src, music)) {
body.raw = PrioritizedRaw(raw, music)
}
} else {
// Need to initialize this grouping.
musicBrainzIdGroups[raw.musicBrainzId] =
Grouping(PrioritizedRaw(raw, music), mutableSetOf(music))
}
} else {
// Need to initialize this grouping.
tree[nameKey] =
mutableMapOf(
raw.musicBrainzId to Grouping(PrioritizedRaw(raw, music), mutableSetOf(music)))
}
}
private inline fun <R, M : Music> pruneMusicBrainzIdTree(
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, M>>>,
prioritize: (old: M, new: M) -> Boolean
) {
for ((_, musicBrainzIdGroups) in tree) {
var nullGroup = musicBrainzIdGroups[null]
if (nullGroup == null) {
// Full MusicBrainz ID tagging. Nothing to do.
continue
}
// Only partial MusicBrainz ID tagging. For the sake of basic sanity, just
// collapse all of them into the null group.
// TODO: More advanced heuristics eventually (tm)
musicBrainzIdGroups
.filter { it.key != null }
.forEach {
val (_, group) = it
nullGroup.music.addAll(group.music)
if (prioritize(group.raw.src, nullGroup.raw.src)) {
nullGroup.raw = group.raw
}
musicBrainzIdGroups.remove(it.key)
}
}
}
private inline fun <R, M : Music, T : MusicParent> flattenMusicBrainzIdTree(
tree: MutableMap<String?, MutableMap<UUID?, Grouping<R, M>>>,
map: (Grouping<R, M>) -> T
): Set<T> {
val result = mutableSetOf<T>()
for ((_, musicBrainzIdGroups) in tree) {
for (group in musicBrainzIdGroups.values) {
result += map(group)
}
}
return result
}
private fun compareSongTracks(old: SongImpl, new: SongImpl) =
new.track != null &&
(old.track == null ||
new.track < old.track ||
(new.track == old.track && new.name < old.name))
private fun compareAlbumDates(old: AlbumImpl, new: AlbumImpl) =
new.dates != null &&
(old.dates == null ||
new.dates < old.dates ||
(new.dates == old.dates && new.name < old.name))
private fun compareSongDates(old: SongImpl, new: SongImpl) =
new.date != null &&
(old.date == null ||
new.date < old.date ||
(new.date == old.date && new.name < old.name))
}
// TODO: Avoid redundant data creation
class DeviceLibraryImpl(
override val songs: Collection<SongImpl>,
override val albums: Collection<AlbumImpl>,
override val artists: Collection<ArtistImpl>,
override val genres: Collection<GenreImpl>
) : DeviceLibrary {
// Use a mapping to make finding information based on it's UID much faster.
private val songUidMap = buildMap { songs.forEach { put(it.uid, it.finalize()) } }
private val songPathMap = buildMap { songs.forEach { put(it.path, it) } }
private val albumUidMap = buildMap { albums.forEach { put(it.uid, it.finalize()) } }
private val artistUidMap = buildMap { artists.forEach { put(it.uid, it.finalize()) } }
private val genreUidMap = buildMap { genres.forEach { put(it.uid, it.finalize()) } }
// All other music is built from songs, so comparison only needs to check songs.
override fun equals(other: Any?) = other is DeviceLibrary && other.songs == songs
override fun hashCode() = songs.hashCode()
override fun toString() =
"DeviceLibrary(songs=${songs.size}, albums=${albums.size}, " +
"artists=${artists.size}, genres=${genres.size})"
override fun findSong(uid: Music.UID): Song? = songUidMap[uid]
override fun findAlbum(uid: Music.UID): Album? = albumUidMap[uid]
override fun findArtist(uid: Music.UID): Artist? = artistUidMap[uid]
override fun findGenre(uid: Music.UID): Genre? = genreUidMap[uid]
override fun findSongByPath(path: Path) = songPathMap[path]
override fun findSongForUri(context: Context, uri: Uri) =
context.contentResolverSafe.useQuery(
uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)) { cursor ->
cursor.moveToFirst()
// We are weirdly limited to DISPLAY_NAME and SIZE when trying to locate a
// song. Do what we can to hopefully find the song the user wanted to open.
val displayName =
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
val size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
songs.find { it.path.name == displayName && it.size == size }
}
}

View file

@ -1,624 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* DeviceMusicImpl.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.music.device
import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.ParentCover
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.MusicType
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.toAlbumCoverUri
import org.oxycblt.auxio.music.fs.toAudioUri
import org.oxycblt.auxio.music.fs.toSongCoverUri
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.info.ReleaseType
import org.oxycblt.auxio.music.metadata.Separators
import org.oxycblt.auxio.music.metadata.parseId3GenreNames
import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment
import org.oxycblt.auxio.util.positiveOrNull
import org.oxycblt.auxio.util.toUuidOrNull
import org.oxycblt.auxio.util.unlikelyToBeNull
import org.oxycblt.auxio.util.update
/**
* Library-backed implementation of [Song].
*
* @param rawSong The [RawSong] to derive the member data from.
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @param separators The [Separators] to parse multi-value tags with.
* @author Alexander Capehart (OxygenCobalt)
*/
class SongImpl(
private val rawSong: RawSong,
private val nameFactory: Name.Known.Factory,
private val separators: Separators
) : Song {
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicType.SONGS, it) }
?: Music.UID.auxio(MusicType.SONGS) {
// Song UIDs are based on the raw data without parsing so that they remain
// consistent across music setting changes. Parents are not held up to the
// same standard since grouping is already inherently linked to settings.
update(rawSong.name)
update(rawSong.albumName)
update(rawSong.date)
update(rawSong.track)
update(rawSong.disc)
update(rawSong.artistNames)
update(rawSong.albumArtistNames)
}
override val name =
nameFactory.parse(
requireNotNull(rawSong.name) { "Invalid raw ${rawSong.path}: No title" },
rawSong.sortName)
override val track = rawSong.track
override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) }
override val date = rawSong.date
override val uri =
requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.path}: No id" }.toAudioUri()
override val path = requireNotNull(rawSong.path) { "Invalid raw ${rawSong.path}: No path" }
override val mimeType =
MimeType(
fromExtension =
requireNotNull(rawSong.extensionMimeType) {
"Invalid raw ${rawSong.path}: No mime type"
},
fromFormat = null)
override val size = requireNotNull(rawSong.size) { "Invalid raw ${rawSong.path}: No size" }
override val durationMs =
requireNotNull(rawSong.durationMs) { "Invalid raw ${rawSong.path}: No duration" }
override val replayGainAdjustment =
ReplayGainAdjustment(
track = rawSong.replayGainTrackAdjustment, album = rawSong.replayGainAlbumAdjustment)
override val dateAdded =
requireNotNull(rawSong.dateAdded) { "Invalid raw ${rawSong.path}: No date added" }
private var _album: AlbumImpl? = null
override val album: Album
get() = unlikelyToBeNull(_album)
private val _artists = mutableListOf<ArtistImpl>()
override val artists: List<Artist>
get() = _artists
private val _genres = mutableListOf<GenreImpl>()
override val genres: List<Genre>
get() = _genres
override val cover =
rawSong.coverPerceptualHash?.let {
// We were able to confirm that the song had a parsable cover and can be used on
// a per-song basis. Otherwise, just fall back to a per-album cover instead, as
// it implies either a cover.jpg pattern is used (likely) or ExoPlayer does not
// support the cover metadata of a given spec (unlikely).
Cover.Embedded(
requireNotNull(rawSong.mediaStoreId) { "Invalid raw ${rawSong.path}: No id" }
.toSongCoverUri(),
uri,
it)
} ?: Cover.External(requireNotNull(rawSong.albumMediaStoreId).toAlbumCoverUri())
/**
* The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an
* [Album].
*/
val rawAlbum: RawAlbum
/**
* The [RawArtist] instances collated by the [Song]. The artists of the song take priority,
* followed by the album artists. If there are no artists, this field will be a single "unknown"
* [RawArtist]. This can be used to group up [Song]s into an [Artist].
*/
val rawArtists: List<RawArtist>
/**
* The [RawGenre] instances collated by the [Song]. This can be used to group up [Song]s into a
* [Genre]. ID3v2 Genre names are automatically converted to their resolved names.
*/
val rawGenres: List<RawGenre>
private var hashCode: Int = uid.hashCode()
init {
val artistMusicBrainzIds = separators.split(rawSong.artistMusicBrainzIds)
val artistNames = separators.split(rawSong.artistNames)
val artistSortNames = separators.split(rawSong.artistSortNames)
val rawIndividualArtists =
artistNames
.mapIndexed { i, name ->
RawArtist(
artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
name,
artistSortNames.getOrNull(i))
}
// Some songs have the same artist listed multiple times (sometimes with different
// casing!),
// so we need to deduplicate lest finalization reordering fails.
// Since MBID data can wind up clobbered later in the grouper, we can't really
// use it to deduplicate. That means that a hypothetical track with two artists
// of the same name but different MBIDs will be grouped wrong. That is a bridge
// I will cross when I get to it.
.distinctBy { it.name?.lowercase() }
val albumArtistMusicBrainzIds = separators.split(rawSong.albumArtistMusicBrainzIds)
val albumArtistNames = separators.split(rawSong.albumArtistNames)
val albumArtistSortNames = separators.split(rawSong.albumArtistSortNames)
val rawAlbumArtists =
albumArtistNames
.mapIndexed { i, name ->
RawArtist(
albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(),
name,
albumArtistSortNames.getOrNull(i))
}
.distinctBy { it.name?.lowercase() }
rawAlbum =
RawAlbum(
mediaStoreId =
requireNotNull(rawSong.albumMediaStoreId) {
"Invalid raw ${rawSong.path}: No album id"
},
musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(),
name =
requireNotNull(rawSong.albumName) {
"Invalid raw ${rawSong.path}: No album name"
},
sortName = rawSong.albumSortName,
releaseType = ReleaseType.parse(separators.split(rawSong.releaseTypes)),
rawArtists =
rawAlbumArtists
.ifEmpty { rawIndividualArtists }
.ifEmpty { listOf(RawArtist()) })
rawArtists =
rawIndividualArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(RawArtist()) }
val genreNames =
(rawSong.genreNames.parseId3GenreNames() ?: separators.split(rawSong.genreNames))
rawGenres =
genreNames
.map { RawGenre(it) }
.distinctBy { it.name?.lowercase() }
.ifEmpty { listOf(RawGenre()) }
hashCode = 31 * hashCode + rawSong.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
}
override fun hashCode() = hashCode
// Since equality on public-facing music models is not identical to the tag equality,
// we just compare raw instances and how they are interpreted.
override fun equals(other: Any?) =
other is SongImpl &&
uid == other.uid &&
nameFactory == other.nameFactory &&
separators == other.separators &&
rawSong == other.rawSong
override fun toString() = "Song(uid=$uid, name=$name)"
/**
* Links this [Song] with a parent [Album].
*
* @param album The parent [Album] to link to.
*/
fun link(album: AlbumImpl) {
_album = album
}
/**
* Links this [Song] with a parent [Artist].
*
* @param artist The parent [Artist] to link to.
*/
fun link(artist: ArtistImpl) {
_artists.add(artist)
}
/**
* Links this [Song] with a parent [Genre].
*
* @param genre The parent [Genre] to link to.
*/
fun link(genre: GenreImpl) {
_genres.add(genre)
}
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Song].
*/
fun finalize(): Song {
checkNotNull(_album) { "Malformed song ${path}: No album" }
check(_artists.isNotEmpty()) { "Malformed song ${path}: No artists" }
check(_artists.size == rawArtists.size) {
"Malformed song ${path}: Artist grouping mismatch"
}
for (i in _artists.indices) {
// Non-destructively reorder the linked artists so that they align with
// the artist ordering within the song metadata.
val newIdx = _artists[i].getOriginalPositionIn(rawArtists)
val other = _artists[newIdx]
_artists[newIdx] = _artists[i]
_artists[i] = other
}
check(_genres.isNotEmpty()) { "Malformed song ${path}: No genres" }
check(_genres.size == rawGenres.size) { "Malformed song ${path}: Genre grouping mismatch" }
for (i in _genres.indices) {
// Non-destructively reorder the linked genres so that they align with
// the genre ordering within the song metadata.
val newIdx = _genres[i].getOriginalPositionIn(rawGenres)
val other = _genres[newIdx]
_genres[newIdx] = _genres[i]
_genres[i] = other
}
return this
}
}
/**
* Library-backed implementation of [Album].
*
* @param grouping [Grouping] to derive the member data from.
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumImpl(
grouping: Grouping<RawAlbum, SongImpl>,
private val nameFactory: Name.Known.Factory
) : Album {
private val rawAlbum = grouping.raw.inner
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
rawAlbum.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ALBUMS, it) }
?: Music.UID.auxio(MusicType.ALBUMS) {
// Hash based on only names despite the presence of a date to increase stability.
// I don't know if there is any situation where an artist will have two albums with
// the exact same name, but if there is, I would love to know.
update(rawAlbum.name)
update(rawAlbum.rawArtists.map { it.name })
}
override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName)
override val dates: Date.Range?
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
override val durationMs: Long
override val dateAdded: Long
override val cover: ParentCover
private val _artists = mutableListOf<ArtistImpl>()
override val artists: List<Artist>
get() = _artists
override val songs: Set<Song> = grouping.music
private var hashCode = uid.hashCode()
init {
var totalDuration: Long = 0
var minDate: Date? = null
var maxDate: Date? = null
var earliestDateAdded: Long = Long.MAX_VALUE
// Do linking and value generation in the same loop for efficiency.
for (song in grouping.music) {
song.link(this)
if (song.date != null) {
val min = minDate
if (min == null || song.date < min) {
minDate = song.date
}
val max = maxDate
if (max == null || song.date > max) {
maxDate = song.date
}
}
if (song.dateAdded < earliestDateAdded) {
earliestDateAdded = song.dateAdded
}
totalDuration += song.durationMs
}
val min = minDate
val max = maxDate
dates = if (min != null && max != null) Date.Range(min, max) else null
durationMs = totalDuration
dateAdded = earliestDateAdded
cover = ParentCover.from(grouping.raw.src.cover, songs)
hashCode = 31 * hashCode + rawAlbum.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
override fun hashCode() = hashCode
// Since equality on public-facing music models is not identical to the tag equality,
// we just compare raw instances and how they are interpreted.
override fun equals(other: Any?) =
other is AlbumImpl &&
uid == other.uid &&
rawAlbum == other.rawAlbum &&
nameFactory == other.nameFactory &&
songs == other.songs
override fun toString() = "Album(uid=$uid, name=$name)"
/**
* The [RawArtist] instances collated by the [Album]. The album artists of the song take
* priority, followed by the artists. If there are no artists, this field will be a single
* "unknown" [RawArtist]. This can be used to group up [Album]s into an [Artist].
*/
val rawArtists = rawAlbum.rawArtists
/**
* Links this [Album] with a parent [Artist].
*
* @param artist The parent [Artist] to link to.
*/
fun link(artist: ArtistImpl) {
_artists.add(artist)
}
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Album].
*/
fun finalize(): Album {
check(songs.isNotEmpty()) { "Malformed album $name: Empty" }
check(_artists.isNotEmpty()) { "Malformed album $name: No artists" }
check(_artists.size == rawArtists.size) {
"Malformed album $name: Artist grouping mismatch"
}
for (i in _artists.indices) {
// Non-destructively reorder the linked artists so that they align with
// the artist ordering within the song metadata.
val newIdx = _artists[i].getOriginalPositionIn(rawArtists)
val other = _artists[newIdx]
_artists[newIdx] = _artists[i]
_artists[i] = other
}
return this
}
}
/**
* Library-backed implementation of [Artist].
*
* @param grouping [Grouping] to derive the member data from.
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistImpl(
grouping: Grouping<RawArtist, Music>,
private val nameFactory: Name.Known.Factory
) : Artist {
private val rawArtist = grouping.raw.inner
override val uid =
// Attempt to use a MusicBrainz ID first before falling back to a hashed UID.
rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ARTISTS, it) }
?: Music.UID.auxio(MusicType.ARTISTS) { update(rawArtist.name) }
override val name =
rawArtist.name?.let { nameFactory.parse(it, rawArtist.sortName) }
?: Name.Unknown(R.string.def_artist)
override val songs: Set<Song>
override val explicitAlbums: Set<Album>
override val implicitAlbums: Set<Album>
override val durationMs: Long?
override val cover: ParentCover
override lateinit var genres: List<Genre>
private var hashCode = uid.hashCode()
init {
val distinctSongs = mutableSetOf<Song>()
val albumMap = mutableMapOf<Album, Boolean>()
for (music in grouping.music) {
when (music) {
is SongImpl -> {
music.link(this)
distinctSongs.add(music)
if (albumMap[music.album] == null) {
albumMap[music.album] = false
}
}
is AlbumImpl -> {
music.link(this)
albumMap[music] = true
}
else -> error("Unexpected input music $music in $name ${music::class.simpleName}")
}
}
songs = distinctSongs
val albums = albumMap.keys
explicitAlbums = albums.filterTo(mutableSetOf()) { albumMap[it] == true }
implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true }
durationMs = songs.sumOf { it.durationMs }.positiveOrNull()
val singleCover =
when (val src = grouping.raw.src) {
is SongImpl -> src.cover
is AlbumImpl -> src.cover.single
else -> error("Unexpected input source $src in $name ${src::class.simpleName}")
}
cover = ParentCover.from(singleCover, songs)
hashCode = 31 * hashCode + rawArtist.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
// Note: Append song contents to MusicParent equality so that artists with
// the same UID but different songs are not equal.
override fun hashCode() = hashCode
// Since equality on public-facing music models is not identical to the tag equality,
// we just compare raw instances and how they are interpreted.
override fun equals(other: Any?) =
other is ArtistImpl &&
uid == other.uid &&
rawArtist == other.rawArtist &&
nameFactory == other.nameFactory &&
songs == other.songs
override fun toString() = "Artist(uid=$uid, name=$name)"
/**
* Returns the original position of this [Artist]'s [RawArtist] within the given [RawArtist]
* list. This can be used to create a consistent ordering within child [Artist] lists based on
* the original tag order.
*
* @param rawArtists The [RawArtist] instances to check. It is assumed that this [Artist]'s
* [RawArtist] will be within the list.
* @return The index of the [Artist]'s [RawArtist] within the list.
*/
fun getOriginalPositionIn(rawArtists: List<RawArtist>) =
rawArtists.indexOfFirst { it.name?.lowercase() == rawArtist.name?.lowercase() }
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Artist].
*/
fun finalize(): Artist {
// There are valid artist configurations:
// 1. No songs, no implicit albums, some explicit albums
// 2. Some songs, no implicit albums, some explicit albums
// 3. Some songs, some implicit albums, no implicit albums
// 4. Some songs, some implicit albums, some explicit albums
// I'm pretty sure the latter check could be reduced to just explicitAlbums.isNotEmpty,
// but I can't be 100% certain.
check(songs.isNotEmpty() || (implicitAlbums.size + explicitAlbums.size) > 0) {
"Malformed artist $name: Empty"
}
genres =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
.genres(songs.flatMapTo(mutableSetOf()) { it.genres })
.sortedByDescending { genre -> songs.count { it.genres.contains(genre) } }
return this
}
}
/**
* Library-backed implementation of [Genre].
*
* @param grouping [Grouping] to derive the member data from.
* @param nameFactory The [Name.Known.Factory] to interpret name information with.
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreImpl(
grouping: Grouping<RawGenre, SongImpl>,
private val nameFactory: Name.Known.Factory
) : Genre {
private val rawGenre = grouping.raw.inner
override val uid = Music.UID.auxio(MusicType.GENRES) { update(rawGenre.name) }
override val name =
rawGenre.name?.let { nameFactory.parse(it, rawGenre.name) }
?: Name.Unknown(R.string.def_genre)
override val songs: Set<Song>
override val artists: Set<Artist>
override val durationMs: Long
override val cover: ParentCover
private var hashCode = uid.hashCode()
init {
val distinctArtists = mutableSetOf<Artist>()
var totalDuration = 0L
for (song in grouping.music) {
song.link(this)
distinctArtists.addAll(song.artists)
totalDuration += song.durationMs
}
songs = grouping.music
artists = distinctArtists
durationMs = totalDuration
cover = ParentCover.from(grouping.raw.src.cover, songs)
hashCode = 31 * hashCode + rawGenre.hashCode()
hashCode = 31 * hashCode + nameFactory.hashCode()
hashCode = 31 * hashCode + songs.hashCode()
}
override fun hashCode() = hashCode
override fun equals(other: Any?) =
other is GenreImpl &&
uid == other.uid &&
rawGenre == other.rawGenre &&
nameFactory == other.nameFactory &&
songs == other.songs
override fun toString() = "Genre(uid=$uid, name=$name)"
/**
* Returns the original position of this [Genre]'s [RawGenre] within the given [RawGenre] list.
* This can be used to create a consistent ordering within child [Genre] lists based on the
* original tag order.
*
* @param rawGenres The [RawGenre] instances to check. It is assumed that this [Genre] 's
* [RawGenre] will be within the list.
* @return The index of the [Genre]'s [RawGenre] within the list.
*/
fun getOriginalPositionIn(rawGenres: List<RawGenre>) =
rawGenres.indexOfFirst { it.name?.lowercase() == rawGenre.name?.lowercase() }
/**
* Perform final validation and organization on this instance.
*
* @return This instance upcasted to [Genre].
*/
fun finalize(): Genre {
check(songs.isNotEmpty()) { "Malformed genre $name: Empty" }
return this
}
}

View file

@ -1,170 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* RawMusic.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.music.device
import java.util.UUID
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.ReleaseType
/**
* Raw information about a [SongImpl] obtained from the filesystem/Extractor instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
data class RawSong(
/**
* The ID of the [SongImpl]'s audio file, obtained from MediaStore. Note that this ID is highly
* unstable and should only be used for accessing the audio file.
*/
var mediaStoreId: Long? = null,
/** @see Song.dateAdded */
var dateAdded: Long? = null,
/** The latest date the [SongImpl]'s audio file was modified, as a unix epoch timestamp. */
var dateModified: Long? = null,
/** @see Song.path */
var path: Path? = null,
/** @see Song.size */
var size: Long? = null,
/** @see Song.durationMs */
var durationMs: Long? = null,
/** @see Song.mimeType */
var extensionMimeType: String? = null,
/** @see Song.replayGainAdjustment */
var replayGainTrackAdjustment: Float? = null,
/** @see Song.replayGainAdjustment */
var replayGainAlbumAdjustment: Float? = null,
/** @see Music.UID */
var musicBrainzId: String? = null,
/** @see Music.name */
var name: String? = null,
/** @see Music.name */
var sortName: String? = null,
/** @see Song.track */
var track: Int? = null,
/** @see Song.disc */
var disc: Int? = null,
/** @See Song.disc */
var subtitle: String? = null,
/** @see Song.date */
var date: Date? = null,
/** @see Song.cover */
var coverPerceptualHash: String? = null,
/** @see RawAlbum.mediaStoreId */
var albumMediaStoreId: Long? = null,
/** @see RawAlbum.musicBrainzId */
var albumMusicBrainzId: String? = null,
/** @see RawAlbum.name */
var albumName: String? = null,
/** @see RawAlbum.sortName */
var albumSortName: String? = null,
/** @see RawAlbum.releaseType */
var releaseTypes: List<String> = listOf(),
/** @see RawArtist.musicBrainzId */
var artistMusicBrainzIds: List<String> = listOf(),
/** @see RawArtist.name */
var artistNames: List<String> = listOf(),
/** @see RawArtist.sortName */
var artistSortNames: List<String> = listOf(),
/** @see RawArtist.musicBrainzId */
var albumArtistMusicBrainzIds: List<String> = listOf(),
/** @see RawArtist.name */
var albumArtistNames: List<String> = listOf(),
/** @see RawArtist.sortName */
var albumArtistSortNames: List<String> = listOf(),
/** @see RawGenre.name */
var genreNames: List<String> = listOf()
)
/**
* Raw information about an [AlbumImpl] obtained from the component [SongImpl] instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
data class RawAlbum(
/**
* The ID of the [AlbumImpl]'s grouping, obtained from MediaStore. Note that this ID is highly
* unstable and should only be used for accessing the system-provided cover art.
*/
val mediaStoreId: Long,
/** @see Music.uid */
override val musicBrainzId: UUID?,
/** @see Music.name */
override val name: String,
/** @see Music.name */
val sortName: String?,
/** @see Album.releaseType */
val releaseType: ReleaseType?,
/** @see RawArtist.name */
val rawArtists: List<RawArtist>
) : MusicBrainzGroupable
/**
* Raw information about an [ArtistImpl] obtained from the component [SongImpl] and [AlbumImpl]
* instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
data class RawArtist(
/** @see Music.UID */
override val musicBrainzId: UUID? = null,
/** @see Music.name */
override val name: String? = null,
/** @see Music.name */
val sortName: String? = null
) : MusicBrainzGroupable
/**
* Raw information about a [GenreImpl] obtained from the component [SongImpl] instances.
*
* @author Alexander Capehart (OxygenCobalt)
*/
data class RawGenre(
/** @see Music.name */
override val name: String? = null
) : NameGroupable
interface NameGroupable {
val name: String?
}
interface MusicBrainzGroupable : NameGroupable {
val musicBrainzId: UUID?
}
/**
* Represents grouped music information and the prioritized raw information to eventually derive a
* [Music] implementation instance from.
*
* @param raw The current [PrioritizedRaw] that will be used for the finalized music information.
* @param music The child [Music] instances of the music information to be created.
*/
data class Grouping<R, M : Music>(var raw: PrioritizedRaw<R, M>, val music: MutableSet<M>)
/**
* Represents a [RawAlbum], [RawArtist], or [RawGenre] specifically chosen to create a [Music]
* instance from due to it being the most likely source of truth.
*
* @param inner The raw music instance that will be used.
* @param src The [Music] instance that the raw information was derived from.
*/
data class PrioritizedRaw<R, M : Music>(val inner: R, val src: M)

View file

@ -1,122 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
* DirectoryAdapter.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.music.dirs
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.oxycblt.auxio.databinding.ItemMusicDirBinding
import org.oxycblt.auxio.list.recycler.DialogRecyclerView
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater
import timber.log.Timber as L
/**
* [RecyclerView.Adapter] that manages a list of [Path] music directory instances.
*
* @param listener A [DirectoryAdapter.Listener] to bind interactions to.
* @author Alexander Capehart (OxygenCobalt)
*/
class DirectoryAdapter(private val listener: Listener) :
RecyclerView.Adapter<MusicDirViewHolder>() {
private val _dirs = mutableListOf<Path>()
/** The current list of [Path]s, may not line up with [MusicDirectories] due to removals. */
val dirs: List<Path> = _dirs
override fun getItemCount() = dirs.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
MusicDirViewHolder.from(parent)
override fun onBindViewHolder(holder: MusicDirViewHolder, position: Int) =
holder.bind(dirs[position], listener)
/**
* Add a [Path] to the end of the list.
*
* @param path The [Path] to add.
*/
fun add(path: Path) {
if (_dirs.contains(path)) return
L.d("Adding $path")
_dirs.add(path)
notifyItemInserted(_dirs.lastIndex)
}
/**
* Add a list of [Path] instances to the end of the list.
*
* @param path The [Path] instances to add.
*/
fun addAll(path: List<Path>) {
L.d("Adding ${path.size} directories")
val oldLastIndex = path.lastIndex
_dirs.addAll(path)
notifyItemRangeInserted(oldLastIndex, path.size)
}
/**
* Remove a [Path] from the list.
*
* @param path The [Path] to remove. Must exist in the list.
*/
fun remove(path: Path) {
L.d("Removing $path")
val idx = _dirs.indexOf(path)
_dirs.removeAt(idx)
notifyItemRemoved(idx)
}
/** A Listener for [DirectoryAdapter] interactions. */
interface Listener {
/** Called when the delete button on a directory item is clicked. */
fun onRemoveDirectory(dir: Path)
}
}
/**
* A [RecyclerView.Recycler] that displays a [Path]. Use [from] to create an instance.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) :
DialogRecyclerView.ViewHolder(binding.root) {
/**
* Bind new data to this instance.
*
* @param path The new [Path] to bind.
* @param listener A [DirectoryAdapter.Listener] to bind interactions to.
*/
fun bind(path: Path, listener: DirectoryAdapter.Listener) {
binding.dirPath.text = path.resolve(binding.context)
binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(path) }
}
companion object {
/**
* Create a new instance.
*
* @param parent The parent to inflate this instance from.
* @return A new instance.
*/
fun from(parent: View) =
MusicDirViewHolder(ItemMusicDirBinding.inflate(parent.context.inflater))
}
}

View file

@ -1,188 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
* MusicDirsDialog.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.music.dirs
import android.content.ActivityNotFoundException
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.view.ViewCompat
import androidx.core.view.isVisible
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.auxio.music.fs.DocumentPathFactory
import org.oxycblt.auxio.music.fs.Path
import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment
import org.oxycblt.auxio.util.showToast
import timber.log.Timber as L
/**
* Dialog that manages the music dirs setting.
*
* @author Alexander Capehart (OxygenCobalt)
*/
@AndroidEntryPoint
class MusicDirsDialog :
ViewBindingMaterialDialogFragment<DialogMusicDirsBinding>(), DirectoryAdapter.Listener {
private val dirAdapter = DirectoryAdapter(this)
private var openDocumentTreeLauncher: ActivityResultLauncher<Uri?>? = null
@Inject lateinit var documentPathFactory: DocumentPathFactory
@Inject lateinit var musicSettings: MusicSettings
override fun onCreateBinding(inflater: LayoutInflater) =
DialogMusicDirsBinding.inflate(inflater)
override fun onConfigDialog(builder: AlertDialog.Builder) {
builder
.setTitle(R.string.set_dirs)
.setNegativeButton(R.string.lbl_cancel, null)
.setPositiveButton(R.string.lbl_save) { _, _ ->
val newDirs = MusicDirectories(dirAdapter.dirs, isUiModeInclude(requireBinding()))
if (musicSettings.musicDirs != newDirs) {
L.d("Committing changes")
musicSettings.musicDirs = newDirs
}
}
}
override fun onBindingCreated(binding: DialogMusicDirsBinding, savedInstanceState: Bundle?) {
openDocumentTreeLauncher =
registerForActivityResult(
ActivityResultContracts.OpenDocumentTree(), ::addDocumentTreeUriToDirs)
binding.dirsAdd.apply {
ViewCompat.setTooltipText(this, contentDescription)
setOnClickListener {
L.d("Opening launcher")
val launcher =
requireNotNull(openDocumentTreeLauncher) {
"Document tree launcher was not available"
}
try {
launcher.launch(null)
} catch (e: ActivityNotFoundException) {
// User doesn't have a capable file manager.
requireContext().showToast(R.string.err_no_app)
}
}
}
binding.dirsRecycler.apply {
adapter = dirAdapter
itemAnimator = null
}
var dirs = musicSettings.musicDirs
if (savedInstanceState != null) {
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
if (pendingDirs != null) {
dirs =
MusicDirectories(
pendingDirs.mapNotNull(documentPathFactory::fromDocumentId),
savedInstanceState.getBoolean(KEY_PENDING_MODE))
}
}
dirAdapter.addAll(dirs.dirs)
requireBinding().dirsEmpty.isVisible = dirs.dirs.isEmpty()
binding.folderModeGroup.apply {
check(
if (dirs.shouldInclude) {
R.id.dirs_mode_include
} else {
R.id.dirs_mode_exclude
})
updateMode()
addOnButtonCheckedListener { _, _, _ -> updateMode() }
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putStringArrayList(
KEY_PENDING_DIRS, ArrayList(dirAdapter.dirs.map(documentPathFactory::toDocumentId)))
outState.putBoolean(KEY_PENDING_MODE, isUiModeInclude(requireBinding()))
}
override fun onDestroyBinding(binding: DialogMusicDirsBinding) {
super.onDestroyBinding(binding)
openDocumentTreeLauncher = null
binding.dirsRecycler.adapter = null
}
override fun onRemoveDirectory(dir: Path) {
dirAdapter.remove(dir)
requireBinding().dirsEmpty.isVisible = dirAdapter.dirs.isEmpty()
}
/**
* Add a Document Tree [Uri] chosen by the user to the current [MusicDirectories] instance.
*
* @param uri The document tree [Uri] to add, chosen by the user. Will do nothing if the [Uri]
* is null or not valid.
*/
private fun addDocumentTreeUriToDirs(uri: Uri?) {
if (uri == null) {
// A null URI means that the user left the file picker without picking a directory
L.d("No URI given (user closed the dialog)")
return
}
val dir = documentPathFactory.unpackDocumentTreeUri(uri)
if (dir != null) {
dirAdapter.add(dir)
requireBinding().dirsEmpty.isVisible = false
} else {
requireContext().showToast(R.string.err_bad_dir)
}
}
private fun updateMode() {
val binding = requireBinding()
if (isUiModeInclude(binding)) {
binding.dirsModeExclude.icon = null
binding.dirsModeInclude.setIconResource(R.drawable.ic_check_24)
binding.dirsModeDesc.setText(R.string.set_dirs_mode_include_desc)
} else {
binding.dirsModeExclude.setIconResource(R.drawable.ic_check_24)
binding.dirsModeInclude.icon = null
binding.dirsModeDesc.setText(R.string.set_dirs_mode_exclude_desc)
}
}
/** Get if the UI has currently configured [MusicDirectories.shouldInclude] to be true. */
private fun isUiModeInclude(binding: DialogMusicDirsBinding) =
binding.folderModeGroup.checkedButtonId == R.id.dirs_mode_include
private companion object {
const val KEY_PENDING_DIRS = BuildConfig.APPLICATION_ID + ".key.PENDING_DIRS"
const val KEY_PENDING_MODE = BuildConfig.APPLICATION_ID + ".key.SHOULD_INCLUDE"
}
}

View file

@ -1,60 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* FsModule.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.music.fs
import android.content.ContentResolver
import android.content.Context
import android.os.storage.StorageManager
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.oxycblt.auxio.util.getSystemServiceCompat
@Module
@InstallIn(SingletonComponent::class)
class FsModule {
@Provides
fun volumeManager(@ApplicationContext context: Context): VolumeManager =
VolumeManagerImpl(context.getSystemServiceCompat(StorageManager::class))
@Provides
fun mediaStoreExtractor(
@ApplicationContext context: Context,
mediaStorePathInterpreterFactory: MediaStorePathInterpreter.Factory
) = MediaStoreExtractor.from(context, mediaStorePathInterpreterFactory)
@Provides
fun mediaStorePathInterpreterFactory(
volumeManager: VolumeManager
): MediaStorePathInterpreter.Factory = MediaStorePathInterpreter.Factory.from(volumeManager)
@Provides
fun contentResolver(@ApplicationContext context: Context): ContentResolver =
context.contentResolverSafe
}
@Module
@InstallIn(SingletonComponent::class)
interface FsBindsModule {
@Binds
fun documentPathFactory(documentTreePathFactory: DocumentPathFactoryImpl): DocumentPathFactory
}

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