141 Commits
Author SHA1 Message Date
austinried 3bd29f75e6 release: cover the playlist info dialog 2026-10-24 02:16:45 +00:00
austinried d7a3a41ab2 metadata: refresh the artist screenshot with the new biography
Regenerate the store screenshots from the demo library. Only the artist
page changed meaningfully, so keep that capture, using Nine Inch Nails,
which has a biography and three albums.

The demo test now fetches the artist biography and passes artist art to
the home, library and search screens, so a regeneration keeps their art
instead of falling back to letter placeholders.

The captures now come from a 393x786 dp, 440 dpi phone: Pixel XL's
640x360 dp renders text far larger than a real phone shows it. That
phone renders 1080x2161, one pixel over the 2:1 limit both stores
enforce, so captures are written out at 1080x2160.
2026-10-24 02:16:45 +00:00
austinried 05e318f9ab docs: add ReplayGain to the README features 2026-10-24 02:16:45 +00:00
austinried e8fb0ac564 release: split the GitHub notes from the store changelog
The GitHub release body was the 500-char store changelog. Give the
GitHub release its own detailed notes under docs/releases/<tag>.md and
point release.yml at it, leaving the store changelog to be the trimmed
version plus a link back to the GitHub release.

The notes cover everything since 3.0.0, including ReplayGain, the artist
page work, the sort name preference and the fixes.

The store changelog still must exist; the workflow now checks both
files.
2026-10-24 02:16:45 +00:00
austinried 7379f3d93e release: bump to 3.1.0 and add its changelog
Bump versionCode to 14 and versionName to 3.1.0, and add the
changelogs/14.txt release notes covering ReplayGain, artist pages,
sort names, keep-screen-on and the fixes.

No tag: this only prepares the release.
2026-10-24 02:16:45 +00:00
austinried f9151843a6 Revert "ui: show the singular and plural artist rows separately"
This reverts commit 2f95a89682.
2026-10-21 14:24:48 +00:00
austinried 2f95a89682 ui: show the singular and plural artist rows separately
Track info now lists the Artist field and the plural Artists field as
their own rows, hiding Artists when it would repeat the Artist value.
Keep one Artist sort row, also hidden when it matches the artist name.
2026-10-21 14:24:18 +00:00
austinried 319ff4b645 test: pin independent artist and artist sort rows
The info rows do not depend on each other: a track with artists but no
artist sort keeps the artists row, and a track with an artist sort but
no artist name still shows the sort row. Pin both so a later label or
value change cannot start requiring both.
2026-10-21 14:15:40 +00:00
austinried d8ac7fefc7 db: keep playlist owner and sharing in filtered lists
The hand-written playlist projection used by the Downloaded filter and
offline mode did not select the new owner and shared columns, so the
playlist Info dialog lost them there while unfiltered lists had them.
Select both columns and pin the filtered path in LibraryDaoTest.

Also share the artist-name extraction between the subtitle and the
plural label so duplicate or blank refs cannot make them disagree,
accept the XML boolean form "1" for the public flag, and strengthen the
date-adjacency and pluralisation tests.
2026-10-21 14:04:46 +00:00
austinried f46f43b6cd ui: finish the library info dialogs and add playlist info
Sort names and titles are only shown when they differ from the regular
name, so artists without a distinct sort name no longer repeat the name.

Track info uses plural labels for both the artists and their sort name
when the track has more than one artist, and singular labels otherwise.
Added and last played sit together in album info.

Playlists gain an Info dialog with owner, sharing, song count, length,
creation and update dates. Owner and the public flag are synced from
getPlaylists, so a schema migration adds both columns.
2026-10-21 13:54:37 +00:00
austinried 8052e1db76 ui: sit the filter dot on the top bar's centre line
Place the dot's ring so its centre lands on the top bar (8dp into the
28dp glyph box), with the dot's outer edge kept at the bar's end.
2026-10-21 10:14:10 +00:00
austinried edc8782302 ui: round out the floating indicators
Size the offline button to 48dp and lift the cloud-off glyph by its
own off-centre so it sits in the middle of the circle. Give the active
filter dot the old FAB treatment: a 7dp dot with a ring in the button
colour, moved onto the top bar of the filter glyph.
2026-10-21 10:14:10 +00:00
austinried 3ffe38d988 ui: float the offline indicator opposite the library FAB
Drop the strip above the tab bar, which kept the bottom of the library
busy, and go back to a floating tonal cloud-off button at the bottom
start, level with the FAB on the other side. Tapping it still leaves
offline mode, and the tab bar goes back to just tabs and the gear.
2026-10-21 10:14:10 +00:00
austinried b76328fc26 ui: hold the offline strip to the 48dp touch target
Keep the strip's own tint and the clickable row, but give it the
standard minimum interactive height now that the Surface is no longer
padding it invisibly.
2026-10-21 10:14:10 +00:00
austinried 9feaf54cd2 ui: stop the offline strip reserving a 48dp touch target
The clickable Surface enforced Material's minimum interactive size, so
trimming its row only moved the text inside a band that stayed 48dp
tall and kept the gap to the tabs. Make the strip itself clickable
instead, so it sizes from its padding, and pin it at 36dp.
2026-10-21 10:14:10 +00:00
austinried 72e1b61bff ui: tint the offline strip and make it slimmer
Drop the divider and move the strip onto a lightly primary-tinted
version of the bar colour, so it reads as its own band without a rule.
Trim the vertical padding, leaving the strip at 28dp.
2026-10-21 10:14:10 +00:00
austinried bb3abe39b6 ui: tighten the offline strip and separate it from the tabs
Drop the strip's fixed 44dp height so it wraps its content, and draw a
divider between the strip and the tab bar. The label now sits 6dp above
the rule instead of 12dp above the tab icons, which read as one gap.
2026-10-21 10:14:10 +00:00
austinried be08e50314 ui: keep a gap before the first library tab
Put an 8dp spacer at the start of the scrollable content and let the
reveal leave that gap when scrolling back towards the first tab, so the
selected pill no longer sits flush against the screen edge. The spacer
scrolls away, so a tab that is half off is still cut at the edge.
2026-10-21 10:14:10 +00:00
austinried b9a3ae5ed0 ui: tighten the library tab bar and simplify the offline strip
Drop the strip's divider and shorten its label to "Offline mode". Take
the bar's horizontal inset off the scroll container so a tab that is
scrolled half off is cut at the screen edge instead of stopping inside
the bar's gutter; the gear keeps its end padding.
2026-10-21 10:14:10 +00:00
austinried 47787182d0 ui: scroll the library tab strip and explain offline mode
The tab bar measured the selected tab's full label before the trailing
controls, so a long translation or a large font scale squeezed the
settings gear and eventually removed it, and an indicator in the bar
made that happen sooner. Put the tabs in a weighted, horizontally
scrollable strip and keep the gear outside it, then scroll the selected
tab into view when the pager settles, in either layout direction.

Move the offline indicator out of the bar into a strip above the tabs
that names the mode and offers to go online. Offline mode limits every
list, and Home already swaps to downloaded content without saying why;
an icon in the bar could not explain that and competed for the same
width.
2026-10-21 10:14:10 +00:00
austinried aca2bd4be2 library: filter the artist cover fallback before ranking 2026-10-21 10:14:01 +00:00
austinried 677596c21b library: rank artist cover fallback like the materialized list
Reuse the materialized ranking CTE chain for the per-artist fallback so
duplicate covers keep the same album, exercise the four-row cap, and
test every credit path plus source isolation. Resolve the art input as a
pure function and key the artist and covers off the same source.
2026-10-21 10:14:01 +00:00
austinried 1dbfb027fe library: resolve artist art from live credits
Artist detail only used the materialized artist cover list, so credited
track artists without an artists row, or with an empty or stale cover
list, always fell back to the letter placeholder even though their
appearances albums had art. Observe the credit-derived cover query and
use it for the detail art, preferring the cached downloaded covers when
offline. The query covers album, album_artists, song and song_artists
credits.
2026-10-21 10:14:01 +00:00
austinried 8765c5d37b ui: keep the detail background measurements across navigation 2026-10-21 10:13:52 +00:00
austinried 2da0d61d4c library: bound the detail scroll restore and cover grids
Gate the restore on the caller's content being ready instead of an item
count that can include only the header, clear it after clamping to the
available rows, and include offset-only positions. Apply the same saver
to the artist grid so a process-death restore does not open at the top.
2026-10-21 10:13:52 +00:00
austinried 2c6af4b126 library: keep detail scroll positions across navigation 2026-10-21 10:13:52 +00:00
austinried 61774a19ca library: restore detail scroll positions after the rows load
The lazy list restores its saved position before a paging-backed playlist
has replayed its cached pages, so a zero-item first frame clamped it back
to the top. Track the position separately and reapply it once the item
exists, which covers paged playlists as well as the album and artist
lists. The test disposes and restores the scaffold with an empty first
frame to keep this from regressing.
2026-10-21 10:13:52 +00:00
austinried 0602f835dc ui: list the default sort mode first 2026-10-21 06:42:12 +00:00
austinried 45fc169c42 ui: default to regular names when sorting 2026-10-21 06:36:53 +00:00
austinried e487d2e0b1 library: drop the download progress indicator 2026-10-21 06:04:23 +00:00
austinried 6f1e1612e1 ui: open the artist chooser immediately from the menu 2026-10-21 00:30:41 +00:00
austinried 185aaabbc5 ui: match the download ring to the sync spinner 2026-10-20 23:55:59 +00:00
austinried c1a01b9a47 ui: put a download icon inside the progress ring 2026-10-20 15:00:27 +00:00
austinried fbd4c0c4fe ui: separate info labels from their values 2026-10-20 14:58:52 +00:00
austinried c56a6d4f83 add library info dialogs and sort name preference
Album and artist context menus gain an Info dialog covering the plural
album artists, sort names and the play and download details the screens
do not show. Multiple artist links collapse into one ellipsised entry
that opens the artist chooser instead of listing each artist.

Sort lists can now prefer regular names over sort names, or only use
sort names for names without Latin letters. The new plain-name indexes
keep those orders out of a temp B-tree.

Also carry plural artists through the Media3 queue so Now Playing and
the mini player keep them, drop the album artist tap ripple, and show
the aggregate download progress in the tab bar where offline mode used
to sit.
2026-10-20 14:28:32 +00:00
austinried 5de5b618d1 ui: let the third bio line use the full width
Only reserve the action-button width for the lines they actually overlap, so the collapsed bio's More stays on the third full-width line without moving the actions or spacing.
2026-10-20 13:47:36 +00:00
austinried 81d45ad0ad ui: flip the three-cover Y split
Turn the 3-cover layout upside down so the stem runs from the centre to the top edge and the two arms open toward the bottom, leaving one boundary straight up from the centre.
2026-10-20 13:36:59 +00:00
austinried 5583c9c1d6 ui: centre the artist actions with the shuffle button
Nudge the star and overflow row 8dp right, and restore the vertical offset, so the action row and shuffle button share a centre line. Keep the extra collapsed-bio spacing and the gap before the Albums header.
2026-10-20 13:16:24 +00:00
austinried 11a304e7ba ui: keep the artist actions aligned with the shuffle button
Move the breathing room into the bio text instead of offsetting the star and overflow actions, which broke their centre alignment with the shuffle button.
2026-10-20 13:12:51 +00:00
austinried c8de39acb9 ui: give the collapsed artist bio room from its actions
The three-line collapsed bio let its More control sit under the star and overflow buttons, and the collapsed state had no bottom gap before the Albums header. Move the actions up, reserve their width on all three collapsed lines, and add the same bottom gap the expanded state uses.
2026-10-20 13:01:47 +00:00
austinried 77e3fa87f1 library: keep artist art maintenance self-healing and cheap
Move change detection into a desired CTE with UPDATE ... FROM so mass updates do not turn the trailing WHERE into a quadratic correlated scan, and so no-op passes write zero rows. Fold the trigger optimization into the 35->36 migration and re-check/rewrite it from onOpen, so a future migration cannot silently restore the full UPDATE triggers. Re-resolve artist detail art when the active source becomes ready, and cover artist search renames in a Room-level test.
2026-10-20 11:23:02 +00:00
austinried 0a5b3512da library: stop artist art updates from reindexing artist search
The materialized art update rewrote every artist row on every library-changing sync. That fired the FTS5 content-sync triggers for each artist, which was the real cost: the same SQL is ~10ms with the triggers removed and ~4.7s for 100 changed artists with them in place. Only update rows whose cover lists actually changed, and replace the generated artist search triggers with UPDATE OF name, sortName so art-only writes never touch the FTS index. Existing databases get this through migration 36->37; fresh databases get it from the onCreate callback.
2026-10-20 10:13:20 +00:00
austinried 783d7f2ebc library: address artist art review findings
Cap the resolver at four covers, re-rank after deduplication so duplicate covers do not consume the grid budget, carry a thumbnail per fallback step, and advance to the grid when both the full image and its thumbnail fail. Negative-cache failed provider-image fetches per artist URL, clear materialized downloaded covers when all downloads are removed, and drop redundant plumbing.
2026-10-20 09:11:20 +00:00
austinried 18118492c7 library: materialize artist art and provider images
Store the OpenSubsonic artistImageUrl and two materialized cover-id lists on each artist row, backfill them in the 35->36 migration, and refresh downloaded covers only for affected artists when downloads change. Icons and the artist hero now resolve provider art, server artist art, then the same materialized grid synchronously, with a definitive-failure fallback so a dead provider URL does not strand the letter placeholder.

Download artwork also caches the provider image for the track and album artists, falls back to the artist cover when that fetch fails, and keeps both keys across sweeps. Query timing for the materialization pass is down from roughly 3.4s to 1.9s on the 20k-song benchmark.
2026-10-20 08:53:23 +00:00
austinried 0d1118011f ui: use the album grid for artist icons too
Load up to four thumbnail covers per visible artist row on demand and render the same 1-4 layout inside the circular artist icon. Lists stay on the expensive per-row query for now.
2026-10-20 07:27:42 +00:00
austinried 6e60ff6183 test: render the three-cover artist hero grid 2026-10-20 02:50:19 +00:00
austinried 4a85b23383 ui: prototype a multi-album artist hero fallback
When an artist has no portrait, build a 1-4 cover hero from album credits on demand. Uses full-res for one cover and thumbnails for 2-4; three covers use a Y split.
2026-10-20 02:48:18 +00:00
austinried d038a33b36 ui: close the list search on back 2026-10-19 14:30:38 +00:00
austinried fb1c2a02ff ui: show plural artists in song subtitles and info dialog 2026-10-19 14:16:31 +00:00
austinried d81efe168e playback: make Auto the default ReplayGain mode
Use Auto for the preference fallback, the settings initial state and the controller's pre-preference default. Existing stored modes are unchanged.
2026-10-19 14:13:22 +00:00
austinried 0bd8f1bda8 playback: call the context-aware ReplayGain mode Auto
Rename the ReplayGainMode value, string resource and user-facing label from Dynamic to Auto. No behaviour change.
2026-10-19 14:13:22 +00:00
austinried 14ed037996 test: cover more up-next drag states
Cover context drags within the runs on either side of the block while the anchor is the playing track, plus moving the playing up-next track.
2026-10-19 14:13:22 +00:00
austinried e32df2fb08 test: cover up-next queue drag permutations
Exercise context and block drags against the queue authority: all context positions across the up-next block, a far move that crosses the window, a context move while an up-next track plays, and moving the playing up-next track.
2026-10-19 14:13:22 +00:00
austinried 43f0721d26 playback: keep the queue and player in sync across up-next
A context drag that crosses the up-next block moves the block's anchor in the repository but the old player.move used the combined drop position, leaving the two in different orders. Rebuild the window for context moves that happen while a block exists, in both the in-window and window-offset paths.

Also drop the redundant rebuildWindow snapshot parameter, the unused media-item context read-back, and add coverage for shuffled album context, ranged album moves and ranged up-next moves.
2026-10-19 14:13:22 +00:00
austinried 3f1d9b0d8d playback: add dynamic ReplayGain mode
Use album gain for items whose queue entry references an album and track gain for playlists, singles and other mixed contexts, including shuffled album queues. Carry the resolved context on each media item so the audio thread does not need to inspect the queue.

Keep a track moved within an album as an album range so it stays in album context; moves out of playlists, artists, genres and other mixed contexts remain pinned song entries.
2026-10-19 14:13:22 +00:00
austinried 8348e048b5 ui: keep the artist gradient anchored to the hero image
Use symmetric page margins for the artist bio, let lines below the action buttons use the full width, and stop the expanding bio from shifting the hero gradient.
2026-10-19 14:01:30 +00:00
austinried 77190a2af1 ui: drop bio justification and normalize cached biographies
Read normalization heals biographies cached before line breaks were preserved, without waiting for a refresh.
2026-10-19 13:55:16 +00:00
austinried 028685d62b ui: show three lines of the collapsed artist bio 2026-10-19 13:47:43 +00:00
austinried cac1f6af24 ui: add a Less control and preserve bio paragraphs
Keep source line breaks, treat the triple-space paragraph separator some servers return as a blank line, justify wrapped bio lines, and append a bold Less control to the expanded bio.
2026-10-19 13:36:06 +00:00
austinried 98398ab2fe library: keep the cached bio on an empty refresh 2026-10-19 13:14:08 +00:00
austinried b379a22517 library: bound the artist bio request 2026-10-19 13:14:08 +00:00
austinried d4ba9c0faf fix: handle artist bio failure and refresh edge cases
Retry a failed download bio fetch instead of blacklisting the artist for
the rest of the process, and give a failed or offline refresh its own
message so it is not mistaken for a server with no biography.

Wait for the stored row before choosing the bio text, so a cached bio no
longer flashes the loading state on entry, and bound the page refresh so
a stalled response cannot leave the loading state up indefinitely.

Render an oversized first token instead of a blank collapsed line, and
check whether the whole remainder fits in one measurement.
2026-10-19 13:14:08 +00:00
austinried 889bdb452b ui: show and expand the artist biography
Load the biography when the artist page is entered. A stored biography is
rendered immediately while it refreshes in the background; otherwise the
page starts with the loading message and falls back to the empty state.

Collapse a biography to two lines and append a bold More only when the
text does not fit. Tapping that line expands the biography, after which
the first lines keep clear of the star and overflow buttons and the rest
use the full width.
2026-10-19 13:14:08 +00:00
austinried 194787dfcc library: cache on-demand artist biographies
Artist biographies come from getArtistInfo2, which the library sync does
not call. Store them in their own table so a page open can use the last
value immediately and downloads can keep it for offline use, without
pulling a large text column into every artist list query.

A missing row means the artist has never been fetched; a row with a null
biography means the server had none. That lets the page show the loading
state only until the first fetch completes.
2026-10-19 13:14:08 +00:00
austinried 2e54d09acb ui: restore search focus when returning
Keep the query populated and put the cursor back at the end of the search field when the screen is restored.
2026-10-19 13:06:27 +00:00
austinried 4b3a7d1dfa library: keep artist paging off song and album writes
The fallback art reads albums and credits, but a successful sync invalidates through the sources row, so observing those tables only adds refreshes for play, star, and sync writes that do not change artist art.
2026-10-19 12:39:01 +00:00
austinried bb71f4889d ui: finish search focus and artist art review follow-ups
Avoid re-requesting search focus when the screen is restored, keep the cheaper album-credit-first art lookup, and test restoration plus association-table invalidation.
2026-10-19 10:14:01 +00:00
austinried 915225f812 ui: address review feedback on search focus and artist art
Keep the search field from stealing focus again when a result is dismissed, observe album and credit tables in artist paging, and simplify the cover fallback to one ranked union. Cover the association-table and invalidation paths in tests.
2026-10-19 10:03:28 +00:00
austinried 88e83c2a86 ui: polish artist art, chooser navigation, and list search
Fall back to the first album credit cover (then the first track credit) for artist art, keep list searches when opening a result, resume search at the end of the query, and navigate immediately from the artist chooser.
2026-10-19 09:30:18 +00:00
austinried de64e8bc69 ui: localize the collapsed bio More label
Pass the More label from a string resource into the collapsed bio splitter so
the suffix width and the bold span use the translated text.
2026-10-19 08:55:06 +00:00
austinried 0e90f51cfa library: name album and track credits in the shared album rows
Use 'album' and 'track' instead of a 1/0 flag so the credit kind is readable
without a comment. This column is a constant in the SQL, not a stored value,
and only the per-artist credit query compares it, so the text costs nothing
meaningful.
2026-10-19 08:55:06 +00:00
austinried 250d02e7ab ui: localize the empty artist bio 2026-10-19 08:51:50 +00:00
austinried ab32a7bfbe ui: separate the artist appearances section from the album credits 2026-10-19 08:33:55 +00:00
austinried 8941eb6702 ui: punctuate the empty artist bio 2026-10-19 08:32:37 +00:00
austinried 63d140e67e ui: add the artist bio layout states
Default the second row to a greyed "No artist bio available" placeholder.
Keep the collapsed two-line layout with a bold More and the expanded flow
that wraps around the action buttons for the next PR, which will supply the
server biography.
2026-10-19 08:26:43 +00:00
austinried 5758609c25 ui: reserve a second meta row for the artist actions
Keep the type and count on the first row with space reserved for the hanging
shuffle, and put the star and more buttons on the blank second row so they sit
at their old height.
2026-10-19 05:14:13 +00:00
austinried b8b5594d04 ui: move artist actions back onto the meta row
Keep the type and count on the same row as the star and more buttons, and move
that row down to where it sat before the split. Give the Albums and Appears on
headers the album and microphone icons alongside the disc-title styling.
2026-10-19 04:57:33 +00:00
austinried 4f6a292e74 ui: style artist section headers like album disc titles
Use the same uppercase label-large semibold text with letter spacing as the
disc headers on the album screen.
2026-10-19 04:49:33 +00:00
austinried 5dd6e8dab9 ui: lower the artist actions and drop section counts
Move the star and more buttons from the meta line onto the first section
header so they sit below the hanging shuffle button again, and remove the
per-section counts now that the meta line carries the total.
2026-10-19 04:44:26 +00:00
austinried 733a4d2941 ui: pin the downloaded artist album visibility rules
Distinguish the album-credit rule (any downloaded song on the album) from the
track-credit rule (the credited track itself must be downloaded) in the
repository test, so a swap of those clauses fails.
2026-10-19 03:18:16 +00:00
austinried 3198a94df3 ui: cover the artist section split in tests
Assert the combined meta line while loading, cover the downloaded partition
and the plural album/song credit branches, share the album-credit id query
with its plan test, and put the meta line through a translatable string.
2026-10-19 03:14:13 +00:00
austinried 8cdbe64942 ui: split artist album credits from track appearances
Put the artist type and total album count on the single meta row beside the
star and more buttons, then list albums where the artist is an album artist
separately from albums where they only have a track credit. The hero shuffle
button and page-level actions stay where they were.

The split comes from an albumCredit flag on the shared credit rows, so the
online and downloaded album lists are partitioned by the same rule and the
two sections still sum to the stored album count.
2026-10-19 02:57:56 +00:00
austinried 824579c938 library: finish the credit query review nits
Put the download-visibility comment on the constant it describes, keep the
no-scan guards beside the seek assertions, and give the identical-sync test a
positive control for both the artist aggregate and the album play recompute.
2026-10-19 02:03:05 +00:00
austinried 0ac67a124d library: tighten credit query coverage
Share the artist page SQL with its plan test and assert the artist seeks, so
the test fails if the predicate push-down regresses. Add a query-log test that
an identical sync skips the aggregate, stop the downloaded album badge from
counting a song whose album row is missing, and correct comments that
overclaimed the remaining hand-written credit paths.
2026-10-19 01:52:12 +00:00
austinried ce33a2b6fd library: recompute artist aggregates at the sync boundary
Replace the per-credit invalidation with one signal from the sync's write
transactions: if artists, albums or songs changed, the source is marked stale
and a single aggregate pass refreshes counts, plays and the credited flag in
one query. Identical syncs no longer run any of those scans, and a new credit
path cannot forget to invalidate the cache.

A changed sync still reads the credit unions once instead of separately for
counts and plays, and migration 33->34 remains a frozen snapshot rather than
sharing the runtime query text.
2026-10-19 01:32:15 +00:00
austinried d908b5b26d library: share the artist credit membership queries
The artist page, shuffle, download attribution and artist badge each spelled
out the four credit paths. Reuse the shared credited song and album rows, and
keep only the download-aware album queries hand-written, where the per-credit
download visibility genuinely differs.

The shared song and album rows use UNION ALL so the artist list filter can push
its artist predicate into every branch instead of materialising a deduplicated
union; callers that need a set use DISTINCT or COUNT(DISTINCT).
2026-10-19 01:27:24 +00:00
austinried d03dd982b2 library: recompute credits when only a lead artist changes
The credit recompute was keyed on song-to-album moves and relation-table
writes, so a server that changed songs.artistId or albums.artistId while the
plural refs stayed the same left the counts attributed to the old lead. Fold
the lead ids into the change check for both songs and albums.
2026-10-18 14:47:00 +00:00
austinried 65d74faa1d library: count credited albums from album credits too
The artist badge now counts albums from album lead ids and album_artists even
when an album has no songs, and only counts song-credited albums that still
exist, matching what albumsForArtist lists. Preserve the derived albumCount
when the server re-upserts an artist row so an identical sync does not rewrite
it twice, relax the migration guard to either relation table, and share one
album-count order between the plain and downloaded artist lists.
2026-10-18 14:40:16 +00:00
austinried e0cfba489e library: align artist counts with credited songs
Artist badges and play rankings only counted album credits, while the artist
page, shuffle queue and download attribution already included track credits.
Derive albumCount and the artist plays from the same four-way credit union,
count lead ids that the server omits from the plural refs, and bump the song's
credited artists on a local scrobble even when the track has no album.

Sort the downloaded artist list by the downloaded album count it displays
instead of the synced total, and backfill the artist totals on upgrade so the
fix applies before the next sync.
2026-10-18 14:25:07 +00:00
austinried 916da72339 settings: say a peakless preamp cannot boost
The preamp prompt promised extra level unconditionally, but a track whose gain has no peak is capped at unity. Say so in the supporting text so the setting does not overpromise for Opus/R128 libraries.
2026-10-18 13:40:41 +00:00
austinried 87dbabb496 playback: only attenuate a gain with no peak
A peak is the only headroom check the gain code has. When the server sends a gain without one (Navidrome does this for Opus R128 gains, which carry no peak tags by design), a positive gain was applied unchecked and the 16-bit sink saturated.

Cap a peakless gain at unity instead: attenuation still applies, but a boost needs a peak to prove it fits. The cap is unchanged when a peak is present.

Update the tests that expected a peakless boost, and cover the positive preamp that can no longer lift a peakless track and the attenuation that still applies.
2026-10-18 13:35:27 +00:00
austinried 746e2319b9 now playing: refresh neighbours when repeat changes
The adjacent artwork effect keys on the item and queue layout, but the predictions now also depend on the repeat mode. Toggling repeat left the cached neighbours stale, so the edge swipe still resisted until the track changed. Key the effect on the repeat mode too, with a route test that toggles repeat and expects the wrapped previous art.
2026-10-18 08:15:44 +00:00
austinried 4d08b8fb1e playback: share edge wrapping with the art predictions
Lift the wrap arithmetic out of next(), previous() and previousTrack() into nextPosition()/previousPosition() so upcomingItem()/previousItem() cannot drift from the actions again. A one-track queue no longer predicts itself as its own neighbour, online or offline: the wrap would land on the current position, and the actions are a no-op there, so the strip offered a dead swipe.
2026-10-18 08:15:44 +00:00
austinried f1628439be playback: wrap adjacent artwork with repeat
The now playing strip took its neighbours from previousItem() and upcomingItem(), which returned null at the queue edges online even when repeat would wrap. Swiping there resisted and only a hard fling advanced the track, leaving no artwork to slide in. Mirror next() and previousTrack() so the online path wraps with repeat All, and with repeat One for the next slot.
2026-10-18 07:33:58 +00:00
austinried 562ca1607b Remove leftover artist title import 2026-10-18 07:16:40 +00:00
austinried 6b0e705883 Address review: scale artist title cap and cover it with the scrim 2026-10-18 07:16:40 +00:00
austinried 7161121922 Polish downloads, settings, and detail titles
Use right/down chevrons in the download manager and order downloaded
tracks by disc and track number, matching the album screen. Move sync
concurrency to the end of the network settings, let the artist hero name
wrap to six lines, and leave the album hero title unbounded.
2026-10-18 07:16:40 +00:00
austinried a41f77d907 library: fix credited fallback and stale marker writes
Restore the both-tables-empty fallback in the credit recompute, skip
redundant marker writes, append albumless tracks after album tracks, and make
the paged artist query observe sources. Add tests for an interrupted sync and
the partial-relation migration guard.
2026-10-18 07:00:04 +00:00
austinried 9d9550be48 library: reconcile artist credits with playback and downloads
Left-join albums in the artist queue queries so albumless credited songs can
be played, downloaded and deleted. Add sortName to the starred index, persist
a per-source stale-credit marker so an interrupted sync cannot strand artist
counts or hide artists, and recompute artist play data in one grouped pass.
2026-10-18 06:42:40 +00:00
austinried c64cdd5172 library: track whether an artist has credits
Add artists.credited, updated at the end of a sync that changed artists or
credits and backfilled on upgrade when relation data exists. Filter the
artist lists and the downloaded artist list on it, which keeps partially
synced libraries visible and hides display-only rows once credits are known.
2026-10-18 05:59:36 +00:00
austinried 4761772b9a library: keep display-only artists out of the artist lists
Filter the paged, search and home artist lists to artists referenced by the
plural credits, falling back to the full list while the relation tables are
still empty. Display names keep using the singular tag and navigation keeps
using the plural refs.
2026-10-18 05:51:55 +00:00
austinried 0c2e85134b library: fix sort-name resolution and migration rebuild
Collect every credited artist id when resolving album and song sort keys so
the singular id and plural refs can both be looked up, and add regression
tests for the compilation and stale-sort-name cases.

Drop the FTS tables before the backfill so the old triggers do not index rows
that are about to be discarded, and rely on createSearchIndex's rebuild
instead of rebuilding a second time. Treat a whitespace-only artist sortName
as absent, and use the sort keys in the downloads tree too.
2026-10-18 05:26:59 +00:00
austinried 7d8a2dae9f library: sort and search by server sort names
Parse the OpenSubsonic sortName attributes, store sort keys on artists,
albums and songs, and sort on them instead of the display names. Resolve
album and track artist sort names from the artists table, and include the
keys in the FTS content so romaji sort names are searchable as well.

Schema 31 rebuilds the sort indexes and search tables, and backfills
existing rows from their display names.
2026-10-18 05:16:41 +00:00
austinried 891406eab9 library: unify artist credit totals and backfill song counts
Use songCount as the total for both artist status queries and stop excluding
albumless tracks from download attribution, so the Artists badge and the
artist page agree. Backfill song counts in the 29->30 migration for offline
upgrades, and recompute only when a song moves albums or its credits change
rather than on every play-metadata sync.
2026-10-18 04:14:29 +00:00
austinried ba77b31dc4 library: store artist song counts for download badges
Add artists.songCount, recomputed after syncs that change albums or songs,
and derive the Artists tab status map from the download rows plus that count
instead of walking every song. The badge query is now proportional to
downloads rather than library size.
2026-10-18 04:02:48 +00:00
austinried ef0c34d973 library: keep artist queries index-driven on large libraries
Rewrite artist queue membership as a UNION of indexed song-id branches
instead of an OR that scanned every song, and recompute artist play data from
an explicit credited-album set instead of scanning all albums per artist.

Run PRAGMA optimize after syncing songs and before play aggregation, and
derive downloaded artist lists and filters from downloads rather than all
songs. Count downloaded albums per artist with artist-first branches.

Extend the query timing harness with the artist playback, download and status
queries plus a partial-download fixture.
2026-10-18 03:40:16 +00:00
austinried 97d33a781a library: fall back to the plural artist names for display
Keep the server's combined legacy artist/albumArtist string when it is
present, prefer the OpenSubsonic displayArtist when it is not, and otherwise
join the plural artist refs with Navidrome's separator. The normalized value
is stored in the display columns, so song rows, album cards, now playing,
menu subtitles, downloads, search and artist ordering all get it.
2026-10-18 02:58:59 +00:00
austinried 6c3028eeb2 library: keep the artist screen fast and avoid placeholder labels
Rewrite the artist album lookup as indexed UNION branches instead of ORs
that scanned the song table, and let artistName take the first indexed row.

Keep the type label and album count blank until the album flow has emitted,
so the page no longer flashes "Artist" and "0 albums" before showing the
real values.
2026-10-18 02:35:55 +00:00
austinried 61a2472ea9 menus: scroll the context sheet when it outgrows the screen
A multi-artist album or song now adds one row per artist, so the sheet can
exceed the screen and clip the remaining actions. Cap it at 560dp and scroll
the content inside.
2026-10-18 02:24:39 +00:00
austinried 9971128078 library: cover every credited artist in plays, downloads and menus
Credit all album artists in play aggregation and recorded plays, include the
relation rows in the downloaded artist list, and fall back to the album artist
display name in download groups.

Also keep the chooser scrollable, close it when the track changes, keep the
singular id in the album-artist label, stop a nested artists container from
swallowing later artist elements, and make the refs encoding length-prefixed
so tag values cannot corrupt it.
2026-10-18 02:15:13 +00:00
austinried daf305f680 library: sync every artist credited on an album or song
OpenSubsonic servers send plural artists/albumArtists refs and can omit the
singular artistId when an album or song has more than one artist. Parse and
store the ordered refs on the item and in indexed album_artists/song_artists
tables, so artist pages, shuffles, downloads and the artist filters include
every credited artist.

Album and now playing artist taps open a chooser when several artists are
credited; context menus get one artist row per artist.
2026-10-18 01:50:34 +00:00
austinried fcfc5561ae settings: name the screen in the keep-screen-on description 2026-10-17 01:14:48 +00:00
austinried a562e4b316 settings: exercise the keep-screen-on toggle in tests
The toggle's behaviour was untested: deleting the Modifier.keepScreenOn(),
hard-coding the row's request or emptying the Switch handler all left the
suite green, so none of the three new code paths were actually pinned.

- NowPlayingKeepScreenOnTest renders Now Playing and asserts a view in the
  activity tree holds the screen on while the flag is set and releases it
  once the flag clears.
- SettingsScreenTest now covers off->on and on->off on the row, and the
  Switch itself, instead of only the off->on row click.
- Remember the keep-screen-on flow so NowPlayingRoute stops tearing down
  and rebuilding its DataStore collector on every position tick.
2026-10-17 01:14:48 +00:00
chiangww 538cb4177a feat: add a keep-screen-on toggle for now playing
A screen timeout mid-album is a common complaint for a music player: the
phone lies on a table while art or lyrics are on screen and the display
sleeps. Add a Settings toggle under Playback, off by default, that holds
the screen awake while now playing is open. The flag rides on the now
playing overlay through Compose's keepScreenOn modifier, and since the
overlay is only composed while expanded, collapsing it releases the flag
without any window-flag lifecycle handling.

Closes austinried/subtracks#201
2026-10-17 01:14:48 +00:00
austinried b2c66bf03e ci: title the release run from its tag 2026-10-16 14:06:14 +00:00
austinried 8bcca2767d settings: clamp the preamp and make the dialog test assert
Review follow-ups.

- The snap clamped nothing, so an out-of-range stored value would show a
  decibel label the slider cannot reach: clamps now live in the snap
  rather than duplicated in the step.
- The screenshot test only rendered the dialog without asserting it
  opened, so deleting the row's click would have left it passing while
  the capture showed the plain list. It now requires the dialog's Reset.
- "Adjust level on top of the ReplayGain" was not grammatical, and the
  mode prompt claimed the server normalises when it only supplies the
  tags.
- Drop a test assertion that restated the production formula.
2026-10-16 13:07:12 +00:00
austinried 5b6c0ef45c settings: name the toolkit in the slider comments
The in-repo comment rule allows a comment only when it names an external
system, version or issue, so both of these now name Compose Material 3,
which is what they are actually about.
2026-10-16 13:07:12 +00:00
austinried d245da20a4 settings: stop the preamp slider drawing a tick per step
Fifty-nine steps across a phone-width track is a tick every few dps,
which reads as a smudge on the bar. The steps are what make the slider
snap in 0.5 dB increments, so they stay; only the tick drawing is
suppressed, through a SliderDefaults.Track that skips drawTick.
2026-10-16 13:07:11 +00:00
austinried 730ea11235 settings: name the setting ReplayGain and rework the preamp dialog
- Call it ReplayGain, which is the name of the standard, in the row, the
  prompt and the preamp prompt.
- Give the slider room under the prompt instead of sitting against it.
- Put Reset in the button row, where it lines up with Cancel and Done,
  rather than above them, and centre the decibel value that is left in
  the content.
- Add a screenshot test that scrolls to the section and opens the dialog,
  which also composes the new rows; the existing screenshot only captures
  the top of the list, so it never rendered them.
2026-10-16 13:07:11 +00:00
austinried 31859b66b6 settings: make the preamp easier to set exactly
The slider alone is awkward for the two things people actually do with a
preamp: zero it, and nudge it by a known amount.

- Minus and plus buttons flank the slider, stepping one grid notch (0.5
  dB) and disabling at the ends.
- A Reset button sits under it, beside the current value, disabled when
  the value is already zero.
- Both go through the same snapping the slider does, so a button cannot
  land off the grid.
2026-10-16 13:07:11 +00:00
austinried 0b4e1d18bc settings: snap the preamp to its grid and drop a duplicate header
Review follow-ups.

- Remove the second "Playback" section header the rows were added
  under; the section already had one, so the screen rendered it twice.
- Snap the preamp to the 0.5 dB grid in the dialog. Material's slider
  snaps in pixel space and converts back, so the value it reports is near
  the configured grid but not on it: -1.0 dB arrives as -0.99999905, was
  displayed verbatim and was stored that way until the next drag. The
  step count is now derived from the range rather than hardcoded.
- Format the decibel value with the resource locale so translated
  layouts get their own decimal separator.
- The controls move to `ReplayGainControls.kt`, since the file now holds
  the grid as well as the labels, and the snap is covered by a test that
  reproduces the off-grid value without it.
2026-10-16 13:07:11 +00:00
austinried 229d6de4c6 settings: add the replay gain controls
Final slice: the screen that drives the mode and preamp preferences the
playback side already reads. Until now `setReplayGainMode` and
`setReplayGainPreampDb` had no production caller, so Off and the preamp
were unreachable.

- `SettingsViewModel` exposes both preferences and writes them back.
- The Playback section gains a "Replay gain" row opening an Off / Track /
  Album choice, and a "Preamp" row opening a slider across the +-15 dB
  range in 0.5 dB steps.
- `ReplayGainMode` is ordered Off, Track, Album so the choice list reads
  that way; the stored value is the name, so the order is not persisted
  and the default is still Track.
- The labels live in their own file so the signed decibel formatting can
  be tested, as `formatTrackTime` does.
2026-10-16 13:07:11 +00:00
austinried 72784ee914 playback: warn once when float output would skip the processor
Float output is the one way the audio processor chain is skipped on this
build, and it is a flag the renderers factory is handed. Nothing sets it,
so nothing happens today; if it is ever turned on, this says so once at
sink construction rather than leaving replay gain silently inapplicable
to high-resolution tracks.

Offload has no equivalent check: it is not a flag this code sets, and
AudioSink.setOffloadMode is only reachable by building the audio renderer
by hand. The factory comment names it instead.
2026-10-16 13:07:08 +00:00
austinried 8f34c958fb playback: reject non-finite gains before the pow, and filter peaks first
Review follow-ups for the apply slice.

- A negative-infinite gain produced 0 dB, not unity: 10^(inf/20) is 0.0,
  which is finite, so the result guard never fired and the track played
  silent — the opposite of what the guard is for. A large-enough finite
  negative gain underflows the same way, and an infinite gain paired with
  a peak survived as the cap. The gain is now rejected before the pow and
  an underflowed result falls back to unity.
- A peak carrying no data shadowed a usable peak in the other slot, so
  trackPeak=0 with albumPeak=0.5 skipped the cap entirely — the gonic
  shape on the default mode. Both peaks are filtered before one is chosen.
- Drop the claim that a period-less flush comes from a sink reset; it does
  not, and the guard stands on its own.
- Trim two comments back to naming the Media3 types.
- Revert the observeStar rename, which was for a path that no longer
  applies the gain.
2026-10-16 13:07:08 +00:00
austinried aee631c08d playback: drop comments that explain rather than name
The in-repo comment rule now allows a comment only when it names an
external system, version or issue, so the ones carrying rationale move
to this message.

- why the gain rides on the media item: PlaybackService can then apply it
  the moment the item becomes current instead of after a library round
  trip
- that ReplayGainController is written from the playback thread by
  onFlush and from the app thread by the preference collector
- that an empty passthrough input is the pipeline's own shared buffer,
  which cannot be put into itself
2026-10-16 13:07:08 +00:00
austinried 62409898c1 docs: record how replay gain reaches the audio sink
The Playback section already documents the star button's dependence on
unstable Media3 APIs and what to re-check when bumping the library. The
gain path needs the same treatment: it relies on flush carrying the
timeline and period, on the sink configuring per media item, and on the
processor chain being reached at all.
2026-10-16 13:07:07 +00:00
austinried cf272c4edc playback: apply the gain only from the item flush
Review follow-ups for the apply slice.

- Drop the media item transition application. It never supplied a gain
  the flush had not already supplied, and as a second writer it raced the
  playback thread: it applied the incoming item's gain while the outgoing
  item's buffers were still queued, scaling the previous track's tail by
  the next track's gain (reproduced at +12 dB). The flush is the only
  writer now.

  This reverses the earlier decision to keep it as a fallback. That was
  made on the premise that it was insurance against a flush that could
  not be resolved. The flush covers every transition, and a period-less
  flush only happens while the sink is being reset, so the premise does
  not hold.

- Ignore a flush that carries no period rather than reading it as "this
  item has no gain". Resetting to unity there would have been wrong if a
  bare flush ever arrived mid-playback; the test that pinned that
  behaviour now pins the opposite.

- Guard the pass-through branch against an empty input, which is the
  pipeline's own shared buffer and cannot be put into itself.

- The controller is written from the playback thread now, so its state
  updates and the recompute that follows them are synchronized rather
  than two independent volatile writes.

- Add ReplayGainSinkTest, which drives the real DefaultAudioSink and
  asserts it hands the processor the item being configured. That is the
  media3 behaviour this whole feature rests on, and it was previously
  asserted only by comments.
2026-10-16 13:07:07 +00:00
austinried c92d28c9dd playback: apply the gain at the media item flush
The app-thread transition callback was only ever a stopgap: media3 posts
it after the playback thread has already rendered, so the first buffers
of a new item could be scaled by the previous item's gain.

media3 forwards the playback timeline and the current period to
AudioProcessor.flush, and DefaultAudioSink flushes the processors on
every sink configuration, which happens at each media item transition
even when the format is unchanged. That is early enough to set the gain
before any of the new item's buffers are processed, on the playback
thread. See androidx/media#418.

The processor resolves the period back to its MediaItem and reports it,
so the gain maths and the preferences stay in ReplayGainController. That
controller is now written from two threads, so its state is volatile.

The transition callback stays as the fallback for a flush that carries no
period: without it a stale gain would persist for a whole track rather
than for a few milliseconds.
2026-10-16 13:07:07 +00:00
austinried 7ce754f95a playback: apply the item's gain in one tested step
Review follow-ups.

- `ReplayGainController.applyItem` owns reading the gain off a
  `MediaItem`, so the transition, the controller and the extras read are
  exercised together rather than only in isolation.
- drop the leftover `setTrack(null)` on a missing active source: now that
  the transition sets the gain from the item, that line only raced it and
  could reset a playing item to unity.
- tests for a zero gain surviving the extras round trip (zero is a real
  gain, not an absent one), for the latest item's gain being the one left
  applied, and for a missing item applying unity.
2026-10-16 13:07:07 +00:00
austinried 5f5ad85dff playback: apply replay gain from the media item
The gain was resolved by querying the library from the media item
transition callback, so the buffers the player had already begun
queueing for the new track could be scaled by the previous track's gain.
A quiet track followed by a hot one could clip at the boundary for as
long as that query took.

Carry the gain on `QueueItem` so `queueMediaItem` puts it on the
`MediaItem` extras, and apply it from the item that just became current,
with no library round trip.

That narrows the window rather than closing it: media3 delivers
`onMediaItemTransition` on the app thread after the playback thread has
already rendered for that iteration, so the very first buffers after a
transition can still carry the previous item's gain. The gap is now one
handler hop instead of a database query.

The trade-off is staleness: if a sync changes a track's gain while that
track is queued, the item keeps the old value until it is rebuilt.
Applying a new gain mid-track is of marginal value anyway.
2026-10-16 13:07:07 +00:00
austinried 3c0372cceb playback: reject non-finite replay gain
Review follow-ups for the apply slice.

The parse guard in the base branch keeps non-finite gains and peaks out
of the library, but nothing here should depend on that: a value already
stored, or written by a future caller, must not be able to silence a
track or blast it at full scale.

- the gain maths ignores a non-finite peak and falls back to unity when
  the result is not finite.
- the processor falls back to unity for a non-finite gain, so
  `roundToInt` can never be handed a NaN.
- the preamp is sanitised on both read and write, since `coerceIn` passes
  NaN straight through.
- the renderers factory is pulled out of `PlaybackService` into
  `ReplayGainRenderersFactory` so a test can assert the sink really is
  built with the processor in its chain; the wiring was otherwise
  untested.
- a null active source clears the gain rather than leaving the previous
  track's applied.
2026-10-16 13:07:07 +00:00
austinried e33784ddd3 feat: apply replay gain during playback
Second slice: read the gain synced in the first one and scale the decoded
PCM by it.

- `ReplayGainProcessor` is a `BaseAudioProcessor` that scales 16-bit and
  float samples, saturating on overflow rather than wrapping. It is
  installed through `DefaultAudioSink`'s audio processor chain, so it runs
  ahead of the default silence-skip and speed processors.
- `replayGainLinearGain` picks the track or album gain according to the
  mode, falls back to the other gain when one is missing, adds the preamp,
  and caps the result at the peak's headroom so a boost cannot clip. A
  non-positive peak means the server has no peak data and is ignored.
- `ReplayGainMode` and the preamp live in `UserPreferences`, defaulting to
  track mode and 0 dB. The settings UI is still to come.
- `PlaybackService` resolves the current song's gain alongside its star
  state and applies it on every media item transition.

The processor only runs while the audio processor chain does, which
`DefaultAudioSink` skips when float output is enabled. That stays off, as
it is by default.
2026-10-16 13:07:07 +00:00
austinried 81e6189ebe sync: keep every replayGain element's fields
A second <replayGain> element for the same song cleared whatever the
first one set, because each field was assigned unconditionally and an
absent attribute parses to null. No server is known to split the object
across elements, but the merge costs nothing and the alternative loses
fields silently.

Also covers peaks arriving without gains, which the existing cases did
not.
2026-10-16 13:06:20 +00:00
austinried 069a0f7f19 sync: drop non-finite replay gain values at the parse boundary
`toFloatOrNull` accepts `"Infinity"`, `"NaN"` and overflowing literals
like `"1e40"`, so a server (or a corrupt tag it read) could put a
non-finite gain or peak into the library. An infinite peak later caps the
applied gain at negative infinity, i.e. silence; an infinite gain clips
every sample to full scale.

Nothing consumes these columns yet, so this is only about not storing
values that cannot mean anything.
2026-10-16 13:06:20 +00:00
austinried 86d0d06e76 sync: fail loudly on upsert column/value drift
Review follow-ups for the replay gain slice.

`upsertChanged` never checked that its hand-maintained column list and
the row values line up, and SQLite binds a missing trailing value as NULL
without error: adding a column to `songColumns` but not to `Song.values()`
would have written NULLs silently. It now asserts the sizes match before
preparing, so that mistake fails the sync instead.

The replay gain sync test also now re-syncs identical values and asserts
no rows are rewritten, pinning the REAL change-detection clause; an
update there rewrites the `song_search` FTS row for no reason.
2026-10-16 13:06:20 +00:00
austinried 526373c10a feat: sync OpenSubsonic replay gain
Store the per-track replay gain a server reports on OpenSubsonic's
`Child.replayGain` so playback can normalise loudness later.

- add trackGain/albumGain/trackPeak/albumPeak (REAL) to the songs table
  and the 27 -> 28 migration
- parse the nested `<replayGain>` element, folding the child-element
  dispatch into a single `songChild` shared by the search and album paths
- teach the sync upsert to bind Float, which it previously rejected

baseGain and fallbackGain are omitted: the servers that send the object
do not populate them, so they would only ever be null columns.

Nothing applies the values yet; this slice only mirrors them.
2026-10-16 13:06:20 +00:00
austinried 7b17e33545 sync: batch the fallback getAlbum song fetches
The `search3`-less fallback emitted one batch per album, and
`SyncService.writeSongs` opens a transaction per batch, so syncing a large
library that way ran one transaction per album — thousands of fsyncs, and an
invalidation round for every list observing `songs` with each.

Accumulate what each `getAlbum` returns and flush at `PAGE_SIZE`, so the
fallback writes the same ~500-row batches every other fetch already does.
Albums are fetched concurrently and complete out of order, so the accumulator
sits behind a mutex and the send happens outside it.

Batches are in-flight memory, so the flush is per rows reached rather than
per album page. Measured per `Song` (strings, boxed longs and genre list
included): ~0.7 KiB, so a 500-row batch is ~334 KiB plus ~51 KiB of the
`names`/`genres` structures `writeSongs` derives, where a whole album page
(~5,500 rows) would be ~3.8 MiB. `albumSongs()` is also a bare `channelFlow`,
whose channel holds 64 elements whatever their size, so 500-row batches would
have made its worst case ~21 MiB; it now carries the same `.buffer(1)` that
`entityBatches()` already uses on itself. The rest of the in-flight rows
scale with `syncConcurrency`, so that leaves the default's batch in hand and
a few MiB at the highest setting.

`SubsonicSourceTest.albumSongsAreEmittedInBoundedBatches` asserts the emitted
sizes, [500, 100] for 600 songs spread over 200 three-song albums, which pins
the carried remainder as well as the flush: verified it fails on the
pre-change code (200 batches of 3) and against a mutant that drops the
remainder ([500, 99]). `docs/architecture.md`'s page cap was 20x out of date,
so it is corrected alongside. `ktlintCheck`, `testDebugUnitTest` and
`lintDebug` pass.
2026-10-16 12:39:42 +00:00
austinried 6cfc1d29f1 ci: fit the whole Nix store in the GitHub cache
The GitHub job's store is ~7.3 GiB but gc-max-store-size-linux is 6G, and
nothing roots the devshells, so cache-nix-action's pre-save `nix store gc`
deletes whichever paths cross the cap before every save. The snapshots land
missing the JDK and the Android SDK components, so both `nix develop` steps
re-download or rebuild them on every run.

Raise the cap to 10G, which clears the store, and bump the key to nix-v3-. The
first run after that downloads the full closure once and saves a complete
snapshot; the gutted nix-v2- snapshots are not restored, so there is one cold
run per ref and v3 is warm from then on.

Compressed at ~0.47x its store the snapshot is ~4.7 GiB at worst, inside the
10 GB per-repo budget. What pushes the repo over that budget today is the four
stale gradle- (v1) entries, which no restore-key can reach and which age out by
LRU.
2026-10-16 09:02:57 +00:00
austinried 9cfa4b62e2 library: keep the album grid aligned across a paging reload
A scrobble writes albums.playCount/played, which invalidates the album
list's PagingSource. Room then refreshes from anchor - initialLoadSize/2,
and with placeholders off that page is the presented list: LazyPagingItems
index zero becomes the page's first album, so the grid re-groups its rows
and every cover moves by (oldStart - newStart) % 3 columns, while the last
row's width becomes the loaded count % 3 instead of the library size % 3.

Enable placeholders for the album pager so an album's index is its
absolute position, and draw a placeholder cell for an unloaded slot. The
other lists are single column, so a reload re-anchors them by key and they
never shifted.

This only keeps the grid still; the reloads themselves are still
unnecessary and are tracked separately.
2026-10-15 13:45:12 +00:00
austinried 25dafb0701 playback: show notification progress for unknown-length streams
Media3 marks a progressive stream live when the data source reports no
length and the extractor no duration, and a live item makes the media
session publish PLAYBACK_POSITION_UNKNOWN, so the notification progress
never advances even though the item carries the library duration.

The app only declares a stream length for a transcode of a track that has
one in a length-estimable format (`declaredStreamLength`), so a chunked
response stays live for an Opus/Ogg/Vorbis transcode (a length estimate
breaks the end-seek they rely on), for a transcode of a track whose
library duration is missing, and for a direct play the server does not
give a Content-Length. The now playing screen shows progress in all of
those because it falls back to the metadata duration; the notification
had nothing to advance.

Wrap the ExoPlayer in a ForwardingPlayer that reports an item with a
declared duration as finite. The session then publishes the real position
and the metadata duration, so the notification shows progress for every
unknown-length stream, not just Opus. The stream is still not seekable;
only the reported liveness changes.
2026-10-15 13:44:52 +00:00
austinried b019efbef9 now playing: stop clipping the cover shadow
The cover strip clipped to the 24dp content inset. A landscape cover fills
that inset, so its drop shadow was cut off on the left and right and only
the top and bottom survived, which reads as a stray band hugging the
artwork. A tile that fills the strip's height had the same problem
vertically.

Drop the clip and space the tiles by TILE_GAP instead. The shadow then
renders whole on all four sides while the swipe target and the artwork
keep the size and the content margins they had, and the spacing leaves a
gap between the tiles while dragging between tracks. It is also what keeps
the neighbours off-screen now that the strip is unclipped.

Refs: #173
2026-10-15 13:37:57 +00:00
137 changed files with 32953 additions and 989 deletions
+3 -3
View File
@@ -9,6 +9,6 @@ runs:
extra_nix_config: experimental-features = nix-command flakes
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.2
with:
primary-key: nix-v2-${{ hashFiles('flake.lock', '**/*.nix') }}
restore-prefixes-first-match: nix-v2-
gc-max-store-size-linux: 6G
primary-key: nix-v3-${{ hashFiles('flake.lock', '**/*.nix') }}
restore-prefixes-first-match: nix-v3-
gc-max-store-size-linux: 10G
+6 -2
View File
@@ -1,5 +1,7 @@
name: Release
run-name: Release ${{ github.ref_name }}
on:
push:
tags:
@@ -83,7 +85,9 @@ jobs:
echo "value=false" >> "$GITHUB_OUTPUT"
fi
- name: Check release notes exist
run: test -f "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
run: |
test -f "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
test -f "docs/releases/$GITHUB_REF_NAME.md"
- name: Write Play service account
if: steps.stable.outputs.value == 'true'
env:
@@ -121,7 +125,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt")
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "docs/releases/$GITHUB_REF_NAME.md")
if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
args+=(--prerelease)
fi
+1
View File
@@ -37,6 +37,7 @@ See [signing/README.md](signing/README.md) for the fingerprints and the commands
- **Adaptive Theming:** Colours drawn from the current song's artwork.
- **Search:** Fast search across the whole library, with per-list sort and filter controls.
- **Playback:** Gapless playback, an editable queue you can reorder and jump through, plus loop and shuffle modes.
- **ReplayGain:** Loudness normalization from the tags your server reports, with Auto, track and album modes and a preamp.
- **Up Next:** Add items into the currently playing queue without rewriting it.
- **Multiple Servers:** Connect several servers and switch between them quickly.
- **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching.
+2 -2
View File
@@ -36,8 +36,8 @@ android {
applicationId = "com.subtracks"
minSdk = 24
targetSdk = 37
versionCode = 13
versionName = "3.0.0"
versionCode = 14
versionName = "3.1.0"
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,7 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase {
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
.setDriver(driver)
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
.addCallback(ArtistSearchTriggerOptimization)
.addCallback(SqliteLifecycleLogging)
.build()
}
@@ -114,6 +114,16 @@ class LoggedMigration(
}
}
internal object ArtistSearchTriggerOptimization : RoomDatabase.Callback() {
override suspend fun onCreate(connection: SQLiteConnection) {
optimizeArtistSearchTriggers(connection)
}
override suspend fun onOpen(connection: SQLiteConnection) {
if (!artistSearchTriggersOptimized(connection)) optimizeArtistSearchTriggers(connection)
}
}
internal object SqliteLifecycleLogging : RoomDatabase.Callback() {
override suspend fun onCreate(connection: SQLiteConnection) {
Log.i("sql", "created fresh database")
@@ -13,18 +13,43 @@ import kotlinx.coroutines.flow.Flow
// A song is attributed to its album artist and to its credited track artist, so a guest artist's
// downloads are visible even though they lead no albums of their own.
private const val ARTIST_DOWNLOAD_ROWS =
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " +
// Starts from songs so status totals include every song the artist is credited on.
internal const val ARTIST_DOWNLOAD_ROWS =
"SELECT credits.artistId AS id, credits.songId AS songId, song_downloads.status AS status " +
"FROM (" + CREDITED_SONG_ROWS + ") credits " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = :sourceId " +
"AND song_downloads.songId = credits.songId"
// Starts from downloads so lists and filters over partially downloaded libraries do not scan songs.
// Kept hand-written for that plan.
internal const val DOWNLOADED_ARTIST_ROWS =
"SELECT albums.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"WHERE song_downloads.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " +
"SELECT album_artists.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL"
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
"WHERE song_downloads.sourceId = :sourceId " +
"UNION ALL " +
"SELECT songs.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"WHERE song_downloads.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId AS id, song_downloads.songId AS songId, " +
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
"FROM song_downloads " +
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
"WHERE song_downloads.sourceId = :sourceId"
@Dao
interface DownloadDao {
@@ -38,10 +63,15 @@ interface DownloadDao {
suspend fun all(): List<SongDownload>
@Query(
"SELECT COUNT(DISTINCT songId) AS total, " +
"SELECT COALESCE((SELECT songCount FROM artists " +
"WHERE sourceId = :sourceId AND id = :artistId), 0) AS total, " +
"COALESCE(statuses.downloaded, 0) AS downloaded, " +
"COALESCE(statuses.downloading, 0) AS downloading " +
"FROM (SELECT " +
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") WHERE id = :artistId",
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
"WHERE id = :artistId AND status IN ('Completed', 'Queued', 'Running')) statuses",
)
fun artistStatus(
sourceId: Long,
@@ -89,12 +119,15 @@ interface DownloadDao {
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
@Query(
"SELECT id, " +
"COUNT(DISTINCT songId) AS total, " +
"SELECT statuses.id, artists.songCount AS total, statuses.downloaded, statuses.downloading " +
"FROM (SELECT id, " +
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") GROUP BY id " +
"HAVING downloaded > 0 OR downloading > 0",
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
"WHERE status IN ('Completed', 'Queued', 'Running') " +
"GROUP BY id) statuses " +
"JOIN artists ON artists.sourceId = :sourceId AND artists.id = statuses.id " +
"WHERE statuses.downloaded > 0 OR statuses.downloading > 0",
)
fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
@@ -114,24 +147,30 @@ interface DownloadDao {
@Query(
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
"al.artistId AS artistId, ar.name AS artistName, d.status AS status, " +
"d.bytes AS bytes, d.total AS total " +
"al.artistId AS artistId, COALESCE(ar.name, al.albumArtist) AS artistName, d.status AS status, " +
"d.bytes AS bytes, d.total AS total, " +
"COALESCE(NULLIF(ar.sortName, ''), NULLIF(al.albumArtistSort, ''), ar.name, al.albumArtist, '') AS artistSort, " +
"COALESCE(NULLIF(al.sortName, ''), al.name, '') AS albumSort, " +
"COALESCE(NULLIF(s.sortName, ''), s.title, '') AS songSort " +
"FROM song_downloads d " +
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
"LEFT JOIN artists ar ON ar.sourceId = al.sourceId AND ar.id = al.artistId " +
"WHERE d.sourceId = :sourceId",
"WHERE d.sourceId = :sourceId ORDER BY s.disc, s.track, s.id",
)
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
@Query(
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
"al.artistId AS albumArtistId, " +
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
"ar.id AS artistRow, ar.coverArt AS artistCoverArt " +
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
"aar.coverArt AS albumArtistCoverArt " +
"FROM song_downloads d " +
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId",
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId " +
"LEFT JOIN artists aar ON aar.sourceId = al.sourceId AND aar.id = al.artistId",
)
suspend fun artwork(): List<DownloadArtwork>
@@ -12,12 +12,14 @@ import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.Disc
import com.subtracks.data.model.DiscKey
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.PlaylistSongItem
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import kotlinx.coroutines.flow.Flow
internal const val PLAYLIST_SONGS_SELECT =
@@ -44,72 +46,305 @@ internal const val ALBUMS_FILTER =
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
"OR instr(lower(albumArtist), lower(:search)) > 0))) "
"OR instr(lower(albumArtist), lower(:search)) > 0 " +
"OR instr(lower(sortName), lower(:search)) > 0 " +
"OR instr(lower(albumArtistSort), lower(:search)) > 0))) "
internal const val ALBUM_ORDER_BY_NAME = "name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ARTIST = "albumArtist COLLATE NOCASE, year, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_ARTIST_REVERSED = "albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, sortName COLLATE NOCASE DESC, id DESC"
// Starred orders keep unstarred rows last in both directions, matching the list grouping, so the
// reversed order is not a literal mirror of the base order.
internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_FREQUENT = "playCount DESC, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_FREQUENT = "playCount DESC, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
private const val ALBUM_NAME_SORT_KEY_MODE =
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
private const val ALBUM_ARTIST_SORT_KEY_MODE =
"(CASE :sortMode WHEN 'Name' THEN albumArtist WHEN 'NonLatin' THEN (CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) ELSE albumArtistSort END) COLLATE NOCASE"
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id"
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE = "$ALBUM_ARTIST_SORT_KEY_MODE, year, $ALBUM_NAME_SORT_KEY_MODE, id"
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED =
"$ALBUM_ARTIST_SORT_KEY_MODE DESC, year DESC, $ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
internal const val ARTIST_CREDITED =
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
// May repeat a (artistId, songId) pair when several credit paths reach it; consumers dedupe.
internal const val CREDITED_SONG_ROWS =
"SELECT songs.artistId AS artistId, songs.id AS songId, songs.albumId AS albumId, " +
"songs.playCount AS playCount, songs.played AS played " +
"FROM songs WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
"FROM song_artists JOIN songs " +
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"WHERE song_artists.sourceId = :sourceId " +
"UNION ALL " +
"SELECT albums.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
"FROM albums JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId, songs.id, songs.albumId, songs.playCount, songs.played " +
"FROM album_artists JOIN songs " +
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId"
// May repeat an (artistId, albumId) pair when several credit paths reach it; consumers dedupe.
internal const val CREDITED_ALBUM_ROWS =
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId, albums.id, 'album' " +
"FROM album_artists JOIN albums " +
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId " +
"UNION ALL " +
"SELECT songs.artistId, songs.albumId, 'track' " +
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId, songs.albumId, 'track' " +
"FROM song_artists JOIN songs " +
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = :sourceId"
internal const val ALBUMS_FOR_ARTIST =
"SELECT albums.* FROM (SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId) credits " +
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
"ORDER BY year DESC, sortName COLLATE NOCASE, id"
internal const val ALBUM_CREDIT_ALBUM_IDS =
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
"WHERE artistId = :artistId AND creditType = 'album'"
// Download visibility follows the credit path: an album credit includes the album when any song is
// downloaded, while a track credit includes it only when the credited track is downloaded.
internal const val DOWNLOADED_ALBUM_ROWS =
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.id, album_artists.artistId FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT songs.albumId, songs.artistId FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
"UNION " +
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
private const val DOWNLOADED_ARTIST_ALBUMS =
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
private const val DOWNLOADED_COVER_ALBUM_ROWS =
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.id, album_artists.artistId FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT songs.albumId, songs.artistId FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"AND sd.status = 'Completed' " +
"UNION " +
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL AND sd.status = 'Completed'"
// Cover art only needs credited albums that actually have a cover, so filter before the window sort.
private const val ARTIST_COVER_CREDITS =
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId, albums.id, 'album' " +
"FROM album_artists JOIN albums " +
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT songs.artistId, songs.albumId, 'track' " +
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId, songs.albumId, 'track' " +
"FROM song_artists JOIN songs " +
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL"
private const val ARTIST_COVER_CREDITS_FOR_ARTIST =
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
"AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId, albums.id, 'album' " +
"FROM album_artists JOIN albums " +
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
"AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT songs.artistId, songs.albumId, 'track' " +
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId, songs.albumId, 'track' " +
"FROM song_artists JOIN songs " +
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
"AND albums.coverArt IS NOT NULL"
private const val ARTIST_COVER_RANKS_TAIL =
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
"ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
"FROM credits JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
"WHERE albums.coverArt IS NOT NULL), " +
"unique_ranked AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM ranked GROUP BY artistId, coverArt), " +
"deduped AS (SELECT artistId, coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
private const val ARTIST_COVER_RANKS =
"credits AS (SELECT artistId, albumId, " +
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
ARTIST_COVER_RANKS_TAIL
private const val ARTIST_COVER_RANKS_FOR_ARTIST =
"credits AS (SELECT artistId, albumId, " +
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
ARTIST_COVER_RANKS_TAIL
internal const val ARTIST_ART_UPDATE_SQL =
"WITH $ARTIST_COVER_RANKS, " +
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
"SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
") GROUP BY artistId), " +
"downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS)), " +
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
"WHERE albums.coverArt IS NOT NULL), " +
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
"downloaded_deduped AS (SELECT artistId, coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
") GROUP BY artistId), " +
"desired AS (SELECT artists.rowid AS rowid, " +
"COALESCE((SELECT coverArts FROM covers WHERE covers.artistId = artists.id), '') AS coverArts, " +
"COALESCE((SELECT coverArts FROM downloaded_covers " +
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
"FROM artists WHERE artists.sourceId = :sourceId) " +
"UPDATE artists SET coverArts = desired.coverArts, downloadedCoverArts = desired.downloadedCoverArts " +
"FROM desired WHERE artists.rowid = desired.rowid AND " +
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
internal const val ARTIST_COVER_ARTS_SQL =
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
"WHERE artistId IN (:artistIds)), " +
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
"WHERE albums.coverArt IS NOT NULL), " +
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
"downloaded_deduped AS (SELECT artistId, coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
") GROUP BY artistId), " +
"desired AS (SELECT artists.rowid AS rowid, " +
"COALESCE((SELECT coverArts FROM downloaded_covers " +
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
"FROM artists WHERE artists.sourceId = :sourceId AND artists.id IN (:artistIds)) " +
"UPDATE artists SET downloadedCoverArts = desired.downloadedCoverArts " +
"FROM desired WHERE artists.rowid = desired.rowid AND " +
"artists.downloadedCoverArts != desired.downloadedCoverArts"
internal const val ARTISTS_FILTER =
"FROM artists WHERE sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:downloadedFilter = 0 OR " +
"EXISTS (SELECT 1 FROM songs " +
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed') " +
"OR EXISTS (SELECT 1 FROM songs " +
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed')) " +
"AND $ARTIST_CREDITED " +
"AND (:downloadedFilter = 0 OR EXISTS (" +
"SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl " +
"WHERE dl.id = artists.id AND dl.status = 'Completed')) " +
"AND (:search = '' " +
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) "
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
"OR instr(lower(sortName), lower(:search)) > 0))) "
internal const val ARTIST_COLUMNS =
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
"artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited"
// With the Downloaded filter on, count the albums that actually have a download so the number under
// an artist matches the albums its detail screen lists.
internal const val ARTISTS_SELECT =
"SELECT artists.sourceId, artists.id, artists.name, " +
"CASE WHEN :downloadedFilter = 1 THEN (" +
"SELECT COUNT(*) FROM (" +
"SELECT dl.id FROM songs " +
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed' " +
"UNION " +
"SELECT dl.id FROM songs " +
"JOIN albums dl ON dl.sourceId = songs.sourceId AND dl.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id AND sd.status = 'Completed'" +
")) ELSE artists.albumCount END AS albumCount, " +
"artists.starred, artists.coverArt, artists.playCount, artists.played "
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
") ELSE artists.albumCount END AS albumCount, " +
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited "
internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, name COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, name COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, name COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, name COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, name COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
private const val ARTIST_NAME_SORT_KEY_MODE =
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE = "$ARTIST_NAME_SORT_KEY_MODE, id"
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ARTIST_NAME_SORT_KEY_MODE DESC, id DESC"
private const val SONG_NAME_SORT_KEY_MODE =
"(CASE :sortMode WHEN 'Name' THEN songs.title WHEN 'NonLatin' THEN (CASE WHEN songs.title GLOB '*[A-Za-z]*' THEN songs.title ELSE songs.sortName END) ELSE songs.sortName END) COLLATE NOCASE"
internal const val SONG_SEARCH_ORDER_SORT_MODE = "$SONG_NAME_SORT_KEY_MODE, songs.id"
internal const val PLAYLISTS_FILTER =
"FROM playlists WHERE sourceId = :sourceId " +
@@ -130,7 +365,7 @@ internal const val PLAYLISTS_SELECT =
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " +
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
") ELSE playlists.songCount END AS songCount, " +
"playlists.created, playlists.changed, playlists.duration "
"playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared "
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
@@ -191,26 +426,63 @@ interface LibraryDao {
)
suspend fun recomputeAlbumPlayData(sourceId: Long)
// INDEXED BY is required: the planner otherwise answers MAX(played) from index_albums_recent
// (sourceId, played), which cannot apply the artistId equality.
// Counts, plays and the credited flag come from the shared credit unions. The download-aware
// queries are separate because their visibility follows the credit path.
@Query(
"UPDATE artists SET " +
"playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0), " +
"played = (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id) " +
"WHERE sourceId = :sourceId AND (" +
"playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0) " +
"OR played IS NOT (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id))",
"WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " +
"credited_albums AS ($CREDITED_ALBUM_ROWS), " +
"totals AS (" +
"SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
"SUM(playCount) AS playCount, MAX(played) AS played " +
"FROM credited GROUP BY artistId" +
"), album_totals AS (" +
"SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
"FROM credited_albums GROUP BY artistId" +
") " +
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
"playCount = c.playCount, played = c.played, credited = c.credited " +
"FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " +
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " +
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
"OR EXISTS (SELECT 1 FROM album_artists aa " +
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
"OR EXISTS (SELECT 1 FROM song_artists sa " +
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
"LEFT JOIN album_totals ON album_totals.id = artists.id " +
"WHERE artists.sourceId = :sourceId) c " +
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
"AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " +
"OR artists.playCount != c.playCount OR artists.played IS NOT c.played " +
"OR artists.credited != c.credited)",
)
suspend fun recomputeArtistPlayData(sourceId: Long)
suspend fun recomputeArtistAggregates(sourceId: Long)
@Query(ARTIST_ART_UPDATE_SQL)
suspend fun recomputeArtistArt(sourceId: Long)
@Query(ARTIST_COVER_ARTS_SQL)
fun artistCoverArts(
sourceId: Long,
artistId: String,
): Flow<List<String>>
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
suspend fun recomputeDownloadedArtistArt(
sourceId: Long,
artistIds: List<String>,
)
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
suspend fun clearDownloadedArtistArt(sourceId: Long)
@Transaction
suspend fun recomputePlayData(sourceId: Long) {
suspend fun recomputeAggregates(sourceId: Long) {
recomputeAlbumPlayData(sourceId)
recomputeArtistPlayData(sourceId)
recomputeArtistAggregates(sourceId)
recomputeArtistArt(sourceId)
}
@Query(
@@ -235,11 +507,16 @@ interface LibraryDao {
@Query(
"UPDATE artists SET playCount = playCount + 1, played = MAX(COALESCE(played, :at), :at) " +
"WHERE sourceId = :sourceId AND id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId)",
"WHERE sourceId = :sourceId AND (" +
"id = (SELECT artistId FROM albums WHERE sourceId = :sourceId AND id = :albumId) " +
"OR id IN (SELECT artistId FROM album_artists WHERE sourceId = :sourceId AND albumId = :albumId) " +
"OR id = (SELECT artistId FROM songs WHERE sourceId = :sourceId AND id = :songId) " +
"OR id IN (SELECT artistId FROM song_artists WHERE sourceId = :sourceId AND songId = :songId))",
)
suspend fun bumpArtistPlay(
sourceId: Long,
albumId: String,
albumId: String?,
songId: String,
at: Long,
)
@@ -249,6 +526,12 @@ interface LibraryDao {
songId: String,
): String?
@Query("SELECT DISTINCT artistId FROM ($CREDITED_SONG_ROWS) WHERE songId IN (:songIds)")
suspend fun creditedArtistIdsForSongs(
sourceId: Long,
songIds: List<String>,
): List<String>
// Counted locally when a scrobble is submitted, so the play sorts move before the next sync; the
// sync then overwrites with the server's own number.
@Transaction
@@ -258,9 +541,9 @@ interface LibraryDao {
at: Long,
) {
bumpSongPlay(sourceId, songId, at)
val albumId = albumIdForSong(sourceId, songId) ?: return
bumpAlbumPlay(sourceId, albumId, at)
bumpArtistPlay(sourceId, albumId, at)
val albumId = albumIdForSong(sourceId, songId)
if (albumId != null) bumpAlbumPlay(sourceId, albumId, at)
bumpArtistPlay(sourceId, albumId, songId, at)
}
// Albums, not songs: these flows re-run on every write to their table, and songs are written
@@ -273,7 +556,7 @@ interface LibraryDao {
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL " +
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit",
"ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
)
fun recentlyPlayedAlbums(
sourceId: Long,
@@ -281,8 +564,8 @@ interface LibraryDao {
): Flow<List<Album>>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL " +
"ORDER BY played DESC, name COLLATE NOCASE, id LIMIT :limit",
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
"ORDER BY played DESC, sortName COLLATE NOCASE, id LIMIT :limit",
)
fun recentlyPlayedArtists(
sourceId: Long,
@@ -291,7 +574,7 @@ interface LibraryDao {
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 " +
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit",
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
)
fun mostPlayedAlbums(
sourceId: Long,
@@ -299,8 +582,8 @@ interface LibraryDao {
): Flow<List<Album>>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 " +
"ORDER BY playCount DESC, name COLLATE NOCASE, id LIMIT :limit",
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id LIMIT :limit",
)
fun mostPlayedArtists(
sourceId: Long,
@@ -309,7 +592,7 @@ interface LibraryDao {
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId " +
"ORDER BY created DESC, name COLLATE NOCASE, id LIMIT :limit",
"ORDER BY created DESC, sortName COLLATE NOCASE, id LIMIT :limit",
)
fun recentlyAddedAlbums(
sourceId: Long,
@@ -318,7 +601,7 @@ interface LibraryDao {
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL AND played < :cutoff " +
"ORDER BY played ASC, name COLLATE NOCASE, id LIMIT :limit",
"ORDER BY played ASC, sortName COLLATE NOCASE, id LIMIT :limit",
)
fun rediscoverAlbums(
sourceId: Long,
@@ -330,7 +613,7 @@ interface LibraryDao {
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit",
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
)
fun recentlyStarredSongs(
sourceId: Long,
@@ -339,22 +622,24 @@ interface LibraryDao {
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
"ORDER BY name COLLATE NOCASE, id LIMIT :limit",
"ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
)
fun searchAlbums(
sourceId: Long,
search: String,
limit: Int,
sortMode: String = "",
): Flow<List<Album>>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER " +
"ORDER BY name COLLATE NOCASE, id LIMIT :limit",
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
"ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
)
fun searchArtists(
sourceId: Long,
search: String,
limit: Int,
sortMode: String = "",
): Flow<List<Artist>>
@Query(
@@ -371,12 +656,13 @@ interface LibraryDao {
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit",
"ORDER BY $SONG_SEARCH_ORDER_SORT_MODE LIMIT :limit",
)
fun searchSongs(
sourceId: Long,
search: String,
limit: Int,
sortMode: String = "",
): Flow<List<AlbumSongItem>>
@Query(
@@ -510,42 +796,48 @@ interface LibraryDao {
@RawQuery(observedEntities = [Album::class])
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
@RawQuery(observedEntities = [Artist::class])
// The fallback art reads albums and credits, but a successful sync writes sources after the
// library changes, so observing artists and sources avoids invalidating on every song/album write.
@RawQuery(observedEntities = [Artist::class, Source::class])
fun artistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
@RawQuery(observedEntities = [Playlist::class])
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME")
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE")
fun albumsByName(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED")
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED")
fun albumsByNameReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST")
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE")
fun albumsByArtist(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED")
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED")
fun albumsByArtistReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
@@ -628,20 +920,22 @@ interface LibraryDao {
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME")
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE")
fun artistsByName(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED")
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED")
fun artistsByNameReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
@@ -750,32 +1044,38 @@ interface LibraryDao {
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id")
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id")
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id")
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, sortName COLLATE NOCASE, id")
fun homeMostPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE, id")
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, sortName COLLATE NOCASE, id")
fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, name COLLATE NOCASE, id")
@Query(
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
"ORDER BY played DESC, sortName COLLATE NOCASE, id",
)
fun homeRecentlyPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 ORDER BY playCount DESC, name COLLATE NOCASE, id")
@Query(
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
"ORDER BY playCount DESC, sortName COLLATE NOCASE, id",
)
fun homeMostPlayedArtists(sourceId: Long): PagingSource<Int, Artist>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id",
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id",
)
fun starredSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
@Query(
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
"ORDER BY starred DESC, title COLLATE NOCASE, id",
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
)
suspend fun starredSongIds(sourceId: Long): List<String>
@@ -784,7 +1084,7 @@ interface LibraryDao {
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit",
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
)
fun downloadedSongs(
sourceId: Long,
@@ -795,7 +1095,7 @@ interface LibraryDao {
"SELECT songs.id FROM songs " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
)
suspend fun downloadedSongIds(sourceId: Long): List<String>
@@ -804,7 +1104,7 @@ interface LibraryDao {
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
)
fun homeDownloadedSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
@@ -814,28 +1114,16 @@ interface LibraryDao {
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
"WHERE albums.sourceId = :sourceId " +
"GROUP BY albums.sourceId, albums.id " +
"ORDER BY MAX(d.downloadedAt) DESC, albums.name COLLATE NOCASE, albums.id",
"ORDER BY MAX(d.downloadedAt) DESC, albums.sortName COLLATE NOCASE, albums.id",
)
fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query(
"SELECT artists.* FROM artists " +
"JOIN (" +
"SELECT albums.artistId AS artistId, d.downloadedAt AS downloadedAt " +
"FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT songs.artistId AS artistId, d.downloadedAt AS downloadedAt " +
"FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id AND d.status = 'Completed' " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL" +
") dl ON dl.artistId = artists.id " +
"WHERE artists.sourceId = :sourceId " +
"GROUP BY artists.sourceId, artists.id " +
"ORDER BY MAX(dl.downloadedAt) DESC, artists.name COLLATE NOCASE, artists.id",
"SELECT $ARTIST_COLUMNS FROM artists " +
"JOIN (SELECT id, MAX(downloadedAt) AS lastDownloadedAt FROM ($DOWNLOADED_ARTIST_ROWS) " +
"WHERE status = 'Completed' GROUP BY id) dl ON dl.id = artists.id " +
"WHERE artists.sourceId = :sourceId AND $ARTIST_CREDITED " +
"ORDER BY dl.lastDownloadedAt DESC, artists.sortName COLLATE NOCASE, artists.id",
)
fun downloadedArtists(sourceId: Long): PagingSource<Int, Artist>
@@ -844,7 +1132,7 @@ interface LibraryDao {
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.title COLLATE NOCASE, songs.id",
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
)
fun songsByGenre(
sourceId: Long,
@@ -855,7 +1143,7 @@ interface LibraryDao {
"SELECT songs.id FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.title COLLATE NOCASE, songs.id",
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
)
suspend fun songsByGenreIds(
sourceId: Long,
@@ -866,12 +1154,12 @@ interface LibraryDao {
"SELECT COUNT(*) FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"AND (songs.title COLLATE NOCASE < :title OR (songs.title COLLATE NOCASE = :title AND songs.id < :id))",
"AND (songs.sortName COLLATE NOCASE < :sortName OR (songs.sortName COLLATE NOCASE = :sortName AND songs.id < :id))",
)
suspend fun genreSongOrdinal(
sourceId: Long,
genre: String,
title: String,
sortName: String,
id: String,
): Long
@@ -881,8 +1169,8 @@ interface LibraryDao {
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"AND (d.downloadedAt > (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
"OR (d.downloadedAt = (SELECT downloadedAt FROM song_downloads WHERE sourceId = :sourceId AND songId = :id) " +
"AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
"AND (s.sortName COLLATE NOCASE < (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
)
suspend fun downloadedSongOrdinal(
sourceId: Long,
@@ -894,8 +1182,8 @@ interface LibraryDao {
"WHERE s.sourceId = :sourceId AND s.starred IS NOT NULL " +
"AND (s.starred > (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"OR (s.starred = (SELECT starred FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"AND (s.title COLLATE NOCASE < (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"OR (s.title COLLATE NOCASE = (SELECT title FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
"AND (s.sortName COLLATE NOCASE < (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) " +
"OR (s.sortName COLLATE NOCASE = (SELECT sortName FROM songs WHERE sourceId = :sourceId AND id = :id) AND s.id < :id))))",
)
suspend fun starredSongOrdinal(
sourceId: Long,
@@ -904,7 +1192,7 @@ interface LibraryDao {
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " +
"ORDER BY year DESC, name COLLATE NOCASE, id",
"ORDER BY year DESC, sortName COLLATE NOCASE, id",
)
fun albumsByDecade(
sourceId: Long,
@@ -930,22 +1218,40 @@ interface LibraryDao {
albumId: String,
): Album?
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND id = :artistId")
@Query("SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND id = :artistId")
suspend fun artistOnce(
sourceId: Long,
artistId: String,
): Artist?
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND id = :artistId")
@Query("SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND id = :artistId")
fun artist(
sourceId: Long,
artistId: String,
): Flow<Artist?>
// A credited track artist may not have an artists row; its name only lives on the songs.
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
fun artistBio(
sourceId: Long,
artistId: String,
): Flow<ArtistBio?>
@Query("SELECT * FROM artist_bios WHERE sourceId = :sourceId AND artistId = :artistId")
suspend fun artistBioOnce(
sourceId: Long,
artistId: String,
): ArtistBio?
@Upsert
suspend fun upsertArtistBio(item: ArtistBio)
// A credited track artist may not have an artists row; its name lives on song_artists or the songs.
@Query(
"SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
"AND artist IS NOT NULL AND artist != '' ORDER BY id LIMIT 1",
"SELECT COALESCE(" +
"(SELECT name FROM song_artists WHERE sourceId = :sourceId AND artistId = :artistId " +
"AND name != '' LIMIT 1), " +
"(SELECT artist FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
"AND artist IS NOT NULL AND artist != '' LIMIT 1))",
)
fun artistName(
sourceId: Long,
@@ -970,16 +1276,18 @@ interface LibraryDao {
songId: String,
): Flow<List<String>>
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND (artistId = :artistId " +
"OR id IN (SELECT albumId FROM songs WHERE sourceId = :sourceId AND artistId = :artistId)) " +
"ORDER BY year DESC, name COLLATE NOCASE, id",
)
@Query(ALBUMS_FOR_ARTIST)
fun albumsForArtist(
sourceId: Long,
artistId: String,
): Flow<List<Album>>
@Query(ALBUM_CREDIT_ALBUM_IDS)
fun albumCreditAlbumIds(
sourceId: Long,
artistId: String,
): Flow<List<String>>
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
fun discs(
sourceId: Long,
@@ -1015,15 +1323,33 @@ interface LibraryDao {
playlistId: String,
): PagingSource<Int, PlaylistSongItem>
// Download visibility follows the credit path: see DOWNLOADED_ARTIST_ALBUMS.
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND (" +
"(artistId = :artistId AND EXISTS (SELECT 1 FROM songs " +
"SELECT * FROM (" +
"SELECT albums.* FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')) " +
"OR id IN (SELECT songs.albumId FROM songs " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.* FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND sd.status = 'Completed')) " +
"ORDER BY year DESC, name COLLATE NOCASE, id",
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.* FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
"UNION " +
"SELECT albums.* FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
)
fun albumsForArtistDownloaded(
sourceId: Long,
@@ -5,6 +5,7 @@ import androidx.sqlite.SQLiteStatement
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SortNameMode
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
// the resulting PagingSource is not invalidated by download writes (including progress updates).
@@ -15,15 +16,19 @@ internal const val ALBUMS_FILTER_BASE =
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
"OR instr(lower(albumArtist), lower(?)) > 0))) "
"OR instr(lower(albumArtist), lower(?)) > 0 " +
"OR instr(lower(sortName), lower(?)) > 0 " +
"OR instr(lower(albumArtistSort), lower(?)) > 0))) "
internal const val ARTISTS_FILTER_BASE =
"FROM artists WHERE sourceId = ? " +
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
"AND $ARTIST_CREDITED " +
"AND (? = '' " +
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
"OR instr(lower(sortName), lower(?)) > 0))) "
internal const val PLAYLISTS_FILTER_BASE =
"FROM playlists WHERE sourceId = ? " +
@@ -38,8 +43,9 @@ internal fun albumsDefaultQuery(
search: String,
sort: AlbumSort,
descending: Boolean,
sortNameMode: SortNameMode = SortNameMode.SortName,
): RoomRawQuery {
val order = albumOrder(sort, descending)
val order = albumOrder(sort, descending, sortNameMode)
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindAlbumFilters(sourceId, starredFilter, search)
}
@@ -53,10 +59,10 @@ internal fun rediscoverAlbumsQuery(
): RoomRawQuery =
RoomRawQuery(
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
"ORDER BY played ASC, name COLLATE NOCASE, id",
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
) { stmt ->
stmt.bindAlbumFilters(sourceId, starredFilter, search)
stmt.bindLong(11, cutoff)
stmt.bindLong(13, cutoff)
}
private fun SQLiteStatement.bindAlbumFilters(
@@ -74,6 +80,8 @@ private fun SQLiteStatement.bindAlbumFilters(
bindText(8, search)
bindText(9, search)
bindText(10, search)
bindText(11, search)
bindText(12, search)
}
internal fun artistsDefaultQuery(
@@ -82,9 +90,10 @@ internal fun artistsDefaultQuery(
search: String,
sort: ArtistSort,
descending: Boolean,
sortNameMode: SortNameMode = SortNameMode.SortName,
): RoomRawQuery {
val order = artistOrder(sort, descending)
return RoomRawQuery("SELECT * $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
val order = artistOrder(sort, descending, sortNameMode)
return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindLong(1, sourceId)
stmt.bindInt(2, starredFilter)
stmt.bindInt(3, starredFilter)
@@ -94,6 +103,7 @@ internal fun artistsDefaultQuery(
stmt.bindText(7, search)
stmt.bindText(8, search)
stmt.bindText(9, search)
stmt.bindText(10, search)
}
}
@@ -114,27 +124,82 @@ internal fun playlistsDefaultQuery(
}
}
internal fun albumNameSortKey(mode: SortNameMode): String =
when (mode) {
SortNameMode.SortName -> "sortName COLLATE NOCASE"
SortNameMode.Name -> "name COLLATE NOCASE"
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
}
internal fun albumArtistSortKey(mode: SortNameMode): String =
when (mode) {
SortNameMode.SortName -> {
"albumArtistSort COLLATE NOCASE"
}
SortNameMode.Name -> {
"albumArtist COLLATE NOCASE"
}
SortNameMode.NonLatin -> {
"(CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) COLLATE NOCASE"
}
}
internal fun artistNameSortKey(mode: SortNameMode): String =
when (mode) {
SortNameMode.SortName -> "sortName COLLATE NOCASE"
SortNameMode.Name -> "name COLLATE NOCASE"
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
}
private fun albumOrder(
sort: AlbumSort,
descending: Boolean,
mode: SortNameMode,
): String =
when (sort) {
AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME
AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST
AlbumSort.Year -> if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
AlbumSort.Added -> if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
AlbumSort.Starred -> if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
AlbumSort.Frequent -> if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
AlbumSort.Recent -> if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
AlbumSort.Name -> {
val name = albumNameSortKey(mode)
if (descending) "$name DESC, id DESC" else "$name, id"
}
AlbumSort.Artist -> {
val artist = albumArtistSortKey(mode)
val name = albumNameSortKey(mode)
if (descending) "$artist DESC, year DESC, $name DESC, id DESC" else "$artist, year, $name, id"
}
AlbumSort.Year -> {
if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
}
AlbumSort.Added -> {
if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
}
AlbumSort.Starred -> {
if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
}
AlbumSort.Frequent -> {
if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
}
AlbumSort.Recent -> {
if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
}
}
private fun artistOrder(
sort: ArtistSort,
descending: Boolean,
mode: SortNameMode,
): String =
when (sort) {
ArtistSort.Name -> {
if (descending) ARTIST_ORDER_BY_NAME_REVERSED else ARTIST_ORDER_BY_NAME
val name = artistNameSortKey(mode)
if (descending) "$name DESC, id DESC" else "$name, id"
}
ArtistSort.AlbumCount -> {
@@ -384,6 +384,285 @@ val MIGRATION_26_27 =
}
}
val MIGRATION_27_28 =
object : Migration(27, 28) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackGain` REAL")
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumGain` REAL")
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `trackPeak` REAL")
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `albumPeak` REAL")
}
}
val MIGRATION_28_29 =
object : Migration(28, 29) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `artists` TEXT NOT NULL DEFAULT ''")
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `artists` TEXT NOT NULL DEFAULT ''")
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `album_artists` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, " +
"`position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
"PRIMARY KEY(`sourceId`, `albumId`, `position`), " +
"FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )",
)
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_album_artists_sourceId_artistId` ON `album_artists` (`sourceId`, `artistId`)",
)
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `song_artists` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, " +
"`position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
"PRIMARY KEY(`sourceId`, `songId`, `position`), " +
"FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )",
)
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_song_artists_sourceId_artistId` ON `song_artists` (`sourceId`, `artistId`)",
)
}
}
val MIGRATION_29_30 =
object : Migration(29, 30) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `songCount` INTEGER NOT NULL DEFAULT 0")
connection.execSQL(
"WITH credited AS (" +
"SELECT sourceId, artistId, songId FROM song_artists " +
"UNION " +
"SELECT sourceId, artistId, id AS songId FROM songs WHERE artistId IS NOT NULL " +
"UNION " +
"SELECT album_artists.sourceId, album_artists.artistId, songs.id " +
"FROM album_artists JOIN songs " +
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
"UNION " +
"SELECT albums.sourceId, albums.artistId, songs.id " +
"FROM albums JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
"WHERE albums.artistId IS NOT NULL" +
"), counts AS (" +
"SELECT sourceId, artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY sourceId, artistId" +
") " +
"UPDATE artists SET songCount = c.songCount " +
"FROM (SELECT artists.sourceId AS sourceId, artists.id AS id, " +
"COALESCE(counts.songCount, 0) AS songCount " +
"FROM artists LEFT JOIN counts " +
"ON counts.sourceId = artists.sourceId AND counts.id = artists.id) c " +
"WHERE artists.sourceId = c.sourceId AND artists.id = c.id " +
"AND artists.songCount != c.songCount",
)
}
}
val MIGRATION_30_31 =
object : Migration(30, 31) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `albumArtistSort` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `artistSort` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
dropSearchIndex(connection, "artist_search")
dropSearchIndex(connection, "album_search")
dropSearchIndex(connection, "song_search")
connection.execSQL("UPDATE `artists` SET `sortName` = `name`")
connection.execSQL("UPDATE `albums` SET `sortName` = `name`")
connection.execSQL("UPDATE `songs` SET `sortName` = `title`")
connection.execSQL(
"UPDATE `albums` SET `albumArtistSort` = COALESCE(" +
"(SELECT a.`sortName` FROM `artists` a " +
"WHERE a.`sourceId` = `albums`.`sourceId` AND a.`id` = `albums`.`artistId`), " +
"(SELECT a.`sortName` FROM `album_artists` aa JOIN `artists` a " +
"ON a.`sourceId` = aa.`sourceId` AND a.`id` = aa.`artistId` " +
"WHERE aa.`sourceId` = `albums`.`sourceId` AND aa.`albumId` = `albums`.`id` " +
"ORDER BY aa.`position` LIMIT 1), " +
"`albumArtist`, '')",
)
connection.execSQL(
"UPDATE `songs` SET `artistSort` = COALESCE(" +
"(SELECT a.`sortName` FROM `artists` a " +
"WHERE a.`sourceId` = `songs`.`sourceId` AND a.`id` = `songs`.`artistId`), " +
"(SELECT a.`sortName` FROM `song_artists` sa JOIN `artists` a " +
"ON a.`sourceId` = sa.`sourceId` AND a.`id` = sa.`artistId` " +
"WHERE sa.`sourceId` = `songs`.`sourceId` AND sa.`songId` = `songs`.`id` " +
"ORDER BY sa.`position` LIMIT 1), " +
"`artist`, '')",
)
ARTIST_INDEXES.forEach { connection.execSQL("DROP INDEX IF EXISTS `$it`") }
ALBUM_INDEXES.forEach { connection.execSQL("DROP INDEX IF EXISTS `$it`") }
ARTIST_INDEX_SQL.forEach { connection.execSQL(it) }
ALBUM_INDEX_SQL.forEach { connection.execSQL(it) }
createSearchIndex(connection, "artist_search", "artists", listOf("name", "sortName"))
createSearchIndex(connection, "album_search", "albums", listOf("name", "albumArtist", "sortName", "albumArtistSort"))
createSearchIndex(connection, "song_search", "songs", listOf("title", "artist", "sortName", "artistSort"))
}
}
private val ARTIST_INDEXES =
listOf("index_artists_name", "index_artists_albumCount", "index_artists_starred", "index_artists_frequent", "index_artists_recent")
private val ALBUM_INDEXES =
listOf(
"index_albums_name",
"index_albums_artist",
"index_albums_year",
"index_albums_added",
"index_albums_starred",
"index_albums_frequent",
"index_albums_recent",
)
private val ARTIST_INDEX_SQL =
listOf(
"CREATE INDEX IF NOT EXISTS `index_artists_name` ON `artists` (`sourceId`, `sortName`, `id`)",
"CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `artists` " +
"(`sourceId` ASC, `albumCount` DESC, `sortName` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `artists` " +
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `artists` " +
"(`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `artists` " +
"(`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)",
)
private val ALBUM_INDEX_SQL =
listOf(
"CREATE INDEX IF NOT EXISTS `index_albums_name` ON `albums` (`sourceId`, `sortName`, `id`)",
"CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `albums` " +
"(`sourceId`, `albumArtistSort`, `year`, `sortName`, `id`)",
"CREATE INDEX IF NOT EXISTS `index_albums_year` ON `albums` " +
"(`sourceId` ASC, `year` DESC, `sortName` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_albums_added` ON `albums` " +
"(`sourceId` ASC, `created` DESC, `sortName` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `albums` " +
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `albums` " +
"(`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `albums` " +
"(`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)",
)
private suspend fun dropSearchIndex(
connection: SQLiteConnection,
table: String,
) {
listOf("BEFORE_UPDATE", "BEFORE_DELETE", "AFTER_UPDATE", "AFTER_INSERT").forEach {
connection.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_${table}_$it`")
}
connection.execSQL("DROP TABLE IF EXISTS `$table`")
}
val MIGRATION_31_32 =
object : Migration(31, 32) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `credited` INTEGER NOT NULL DEFAULT 1")
connection.execSQL(
"UPDATE `artists` SET `credited` = 0 " +
"WHERE EXISTS (SELECT 1 FROM `album_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
"AND EXISTS (SELECT 1 FROM `song_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
"AND NOT EXISTS (SELECT 1 FROM `album_artists` aa " +
"WHERE aa.`sourceId` = `artists`.`sourceId` AND aa.`artistId` = `artists`.`id`) " +
"AND NOT EXISTS (SELECT 1 FROM `song_artists` sa " +
"WHERE sa.`sourceId` = `artists`.`sourceId` AND sa.`artistId` = `artists`.`id`)",
)
}
}
val MIGRATION_32_33 =
object : Migration(32, 33) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `sources` ADD COLUMN `creditsStale` INTEGER NOT NULL DEFAULT 1")
connection.execSQL("DROP INDEX IF EXISTS `index_songs_starred`")
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_songs_starred` ON `songs` " +
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
)
}
}
// Recomputes the artist totals from the same credit unions the sync uses, so badges, the artist
// page and the shuffle queue agree for libraries that predate the change.
val MIGRATION_33_34 =
object : Migration(33, 34) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"WITH credited_songs AS (" +
"SELECT songs.`sourceId` AS sourceId, songs.`artistId` AS artistId, " +
"songs.`id` AS songId, songs.`playCount` AS playCount, songs.`played` AS played " +
"FROM songs WHERE songs.`artistId` IS NOT NULL " +
"UNION " +
"SELECT song_artists.`sourceId`, song_artists.`artistId`, songs.`id`, " +
"songs.`playCount`, songs.`played` " +
"FROM song_artists JOIN songs " +
"ON songs.`sourceId` = song_artists.`sourceId` AND songs.`id` = song_artists.`songId` " +
"UNION " +
"SELECT albums.`sourceId`, albums.`artistId`, songs.`id`, " +
"songs.`playCount`, songs.`played` " +
"FROM albums JOIN songs " +
"ON songs.`sourceId` = albums.`sourceId` AND songs.`albumId` = albums.`id` " +
"WHERE albums.`artistId` IS NOT NULL " +
"UNION " +
"SELECT album_artists.`sourceId`, album_artists.`artistId`, songs.`id`, " +
"songs.`playCount`, songs.`played` " +
"FROM album_artists JOIN songs " +
"ON songs.`sourceId` = album_artists.`sourceId` AND songs.`albumId` = album_artists.`albumId`" +
"), credited_albums AS (" +
"SELECT albums.`sourceId` AS sourceId, albums.`artistId` AS artistId, " +
"albums.`id` AS albumId " +
"FROM albums WHERE albums.`artistId` IS NOT NULL " +
"UNION " +
"SELECT album_artists.`sourceId`, album_artists.`artistId`, albums.`id` " +
"FROM album_artists JOIN albums " +
"ON albums.`sourceId` = album_artists.`sourceId` AND albums.`id` = album_artists.`albumId` " +
"UNION " +
"SELECT songs.`sourceId`, songs.`artistId`, songs.`albumId` " +
"FROM songs JOIN albums " +
"ON albums.`sourceId` = songs.`sourceId` AND albums.`id` = songs.`albumId` " +
"WHERE songs.`artistId` IS NOT NULL " +
"UNION " +
"SELECT song_artists.`sourceId`, song_artists.`artistId`, songs.`albumId` " +
"FROM song_artists JOIN songs " +
"ON songs.`sourceId` = song_artists.`sourceId` AND songs.`id` = song_artists.`songId` " +
"JOIN albums ON albums.`sourceId` = songs.`sourceId` AND albums.`id` = songs.`albumId`" +
"), totals AS (" +
"SELECT sourceId, artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
"SUM(playCount) AS playCount, MAX(played) AS played " +
"FROM credited_songs GROUP BY sourceId, artistId" +
"), album_totals AS (" +
"SELECT sourceId, artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
"FROM credited_albums GROUP BY sourceId, artistId" +
") " +
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
"playCount = c.playCount, played = c.played " +
"FROM (SELECT artists.`sourceId` AS sourceId, artists.`id` AS id, " +
"COALESCE(totals.songCount, 0) AS songCount, " +
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played " +
"FROM artists LEFT JOIN totals " +
"ON totals.sourceId = artists.`sourceId` AND totals.id = artists.`id` " +
"LEFT JOIN album_totals " +
"ON album_totals.sourceId = artists.`sourceId` AND album_totals.id = artists.`id` " +
"WHERE EXISTS (SELECT 1 FROM album_artists WHERE album_artists.`sourceId` = artists.`sourceId`) " +
"OR EXISTS (SELECT 1 FROM song_artists WHERE song_artists.`sourceId` = artists.`sourceId`)" +
") c " +
"WHERE artists.`sourceId` = c.sourceId AND artists.`id` = c.id " +
"AND (artists.`songCount` != c.songCount OR artists.`albumCount` != c.albumCount " +
"OR artists.`playCount` != c.playCount OR artists.`played` IS NOT c.played)",
)
}
}
val MIGRATION_34_35 =
object : Migration(34, 35) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"CREATE TABLE IF NOT EXISTS `artist_bios` (" +
"`sourceId` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `biography` TEXT, " +
"PRIMARY KEY(`sourceId`, `artistId`), " +
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE)",
)
}
}
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
private suspend fun createSearchIndex(
connection: SQLiteConnection,
@@ -416,6 +695,80 @@ private suspend fun createSearchIndex(
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
}
val MIGRATION_35_36 =
object : Migration(35, 36) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `artistImageUrl` TEXT")
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `coverArts` TEXT NOT NULL DEFAULT ''")
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `downloadedCoverArts` TEXT NOT NULL DEFAULT ''")
optimizeArtistSearchTriggers(connection)
backfillArtistArt(connection)
}
}
val MIGRATION_36_37 =
object : Migration(36, 37) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_albums_name_plain` ON `albums` (`sourceId`, `name`, `id`)",
)
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_albums_artist_plain` " +
"ON `albums` (`sourceId`, `albumArtist`, `year`, `name`, `id`)",
)
connection.execSQL(
"CREATE INDEX IF NOT EXISTS `index_artists_name_plain` ON `artists` (`sourceId`, `name`, `id`)",
)
}
}
val MIGRATION_37_38 =
object : Migration(37, 38) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `owner` TEXT")
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `shared` INTEGER NOT NULL DEFAULT 0")
}
}
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
val triggers = mutableListOf<String>()
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
statement.bindText(1, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
statement.bindText(2, "room_fts_content_sync_artist_search_AFTER_UPDATE")
while (statement.step()) triggers += statement.getText(0)
}
return triggers.size == 2 && triggers.all { it.contains("UPDATE OF `name`, `sortName`") }
}
internal fun optimizeArtistSearchTriggers(connection: SQLiteConnection) {
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE")
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE")
connection.execSQL(
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE " +
"BEFORE UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
"DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END",
)
connection.execSQL(
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE " +
"AFTER UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
"INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) " +
"VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END",
)
}
private suspend fun backfillArtistArt(connection: SQLiteConnection) {
val sourceIds = mutableListOf<Long>()
connection.prepare("SELECT id FROM sources").use { statement ->
while (statement.step()) sourceIds += statement.getLong(0)
}
sourceIds.forEach { sourceId ->
connection.prepare(ARTIST_ART_UPDATE_SQL).use { statement ->
statement.bindLong(1, sourceId)
statement.step()
}
}
}
val MIGRATIONS: Array<Migration> =
arrayOf(
MIGRATION_1_2,
@@ -444,4 +797,15 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_24_25,
MIGRATION_25_26,
MIGRATION_26_27,
MIGRATION_27_28,
MIGRATION_28_29,
MIGRATION_29_30,
MIGRATION_30_31,
MIGRATION_31_32,
MIGRATION_32_33,
MIGRATION_33_34,
MIGRATION_34_35,
MIGRATION_35_36,
MIGRATION_36_37,
MIGRATION_37_38,
)
@@ -19,13 +19,16 @@ private const val ALBUM_SONGS_ORDER = " ORDER BY songs.disc, songs.track, songs.
private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
private const val ARTIST_SONG_IDS =
"(SELECT songId FROM ($CREDITED_SONG_ROWS) WHERE artistId = :artistId)"
private const val ARTIST_SONGS_SELECT =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)"
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
private const val ARTIST_SONGS_ORDER =
" ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
" ORDER BY albums.id IS NULL, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
private const val SONG_SQL =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
@@ -82,8 +85,8 @@ interface QueueDao {
@Query(
"SELECT COUNT(*) FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)",
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS",
)
suspend fun artistLength(
sourceId: Long,
@@ -190,8 +193,8 @@ interface QueueDao {
@Query(
"SELECT songs.id FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)" +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS" +
ARTIST_SONGS_ORDER,
)
suspend fun artistSongIds(
@@ -231,7 +234,7 @@ interface QueueDao {
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
"ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
)
suspend fun genreSongs(
sourceId: Long,
@@ -244,7 +247,7 @@ interface QueueDao {
"SELECT songs.id FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.title COLLATE NOCASE, songs.id",
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
)
suspend fun genreSongIds(
sourceId: Long,
@@ -261,7 +264,7 @@ interface QueueDao {
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
)
suspend fun starredSongs(
sourceId: Long,
@@ -271,7 +274,7 @@ interface QueueDao {
@Query(
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
"ORDER BY starred DESC, title COLLATE NOCASE, id",
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
)
suspend fun starredSongIds(sourceId: Long): List<String>
@@ -280,7 +283,7 @@ interface QueueDao {
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
)
suspend fun downloadedSongs(
sourceId: Long,
@@ -292,7 +295,7 @@ interface QueueDao {
"SELECT songs.id FROM song_downloads d " +
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id",
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
)
suspend fun downloadedSongIds(sourceId: Long): List<String>
}
@@ -29,6 +29,22 @@ interface SourcesDao {
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
suspend fun activeSourceIdOnce(): Long?
@Query("SELECT creditsStale FROM sources WHERE id = :sourceId")
suspend fun creditsStale(sourceId: Long): Boolean?
@Query("UPDATE sources SET creditsStale = :stale WHERE id = :sourceId AND creditsStale != :stale")
suspend fun setCreditsStale(
sourceId: Long,
stale: Boolean,
)
@Query("UPDATE sources SET name = :name, address = :address WHERE id = :sourceId")
suspend fun updateSourceDetails(
sourceId: Long,
name: String,
address: String,
)
@Insert
suspend fun insertSource(source: Source): Long
@@ -4,8 +4,11 @@ import androidx.room3.ColumnTypeConverters
import androidx.room3.Database
import androidx.room3.RoomDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumArtistRef
import com.subtracks.data.model.AlbumSearch
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.ArtistRefsConverter
import com.subtracks.data.model.ArtistSearch
import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.Disc
@@ -20,10 +23,12 @@ import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKindConverter
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongArtistRef
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.SongGenre
import com.subtracks.data.model.SongSearch
import com.subtracks.data.model.Source
import com.subtracks.data.model.StringListConverter
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.UpNextEntry
@@ -32,15 +37,18 @@ import com.subtracks.data.model.UpNextEntry
Source::class,
SubsonicSource::class,
Artist::class,
ArtistBio::class,
ArtistSearch::class,
Album::class,
AlbumSearch::class,
Disc::class,
AlbumArtistRef::class,
Playlist::class,
PlaylistSearch::class,
PlaylistSong::class,
Song::class,
SongGenre::class,
SongArtistRef::class,
SongSearch::class,
QueueEntry::class,
UpNextEntry::class,
@@ -49,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class,
PendingAction::class,
],
version = 27,
version = 38,
exportSchema = true,
)
@ColumnTypeConverters(
@@ -57,6 +65,8 @@ import com.subtracks.data.model.UpNextEntry
DownloadStatusConverter::class,
DownloadErrorConverter::class,
PendingActionKindConverter::class,
ArtistRefsConverter::class,
StringListConverter::class,
)
abstract class SubtracksDatabase : RoomDatabase() {
abstract fun libraryDao(): LibraryDao
@@ -10,3 +10,8 @@ fun coverArtKey(
coverArt: String,
thumbnail: Boolean,
): String = "$sourceId:$coverArt:$thumbnail"
fun artistImageKey(
sourceId: Long,
artistId: String,
): String = "$sourceId:artist-image:$artistId"
@@ -58,9 +58,14 @@ data class SongDownload(
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
) {
val progress: Float?
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
get() = fractionOf(bytes, total)
}
internal fun fractionOf(
bytes: Long,
total: Long,
): Float? = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
enum class DownloadList { Album, Playlist, Artist }
enum class BulkDownloadAction { Download, Cancel, Delete }
@@ -94,19 +99,24 @@ data class DownloadedSong(
val size: Long = 0,
val bytes: Long = 0,
val total: Long = 0,
val artistSort: String = artistName.orEmpty(),
val albumSort: String = albumName.orEmpty(),
val songSort: String = title,
) {
val progress: Float?
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
get() = fractionOf(bytes, total)
}
data class DownloadArtwork(
val sourceId: Long,
val albumId: String?,
val artistId: String?,
val albumArtistId: String?,
val albumRow: String?,
val artistRow: String?,
val albumCoverArt: String?,
val artistCoverArt: String?,
val albumArtistCoverArt: String?,
) {
val hasMissingLibraryRow: Boolean
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
@@ -18,6 +18,7 @@ data class Source(
val address: String,
val isActive: Boolean = false,
val createdAt: Long,
@ColumnInfo(defaultValue = "1") val creditsStale: Boolean = true,
)
@Entity(
@@ -39,6 +40,11 @@ data class SubsonicSource(
val useTokenAuth: Boolean = true,
)
data class ArtistRef(
val id: String,
val name: String,
)
@Entity(
tableName = "artists",
primaryKeys = ["sourceId", "id"],
@@ -52,25 +58,26 @@ data class SubsonicSource(
],
indices = [
Index("sourceId"),
Index(name = "index_artists_name", value = ["sourceId", "name", "id"]),
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
Index(name = "index_artists_name_plain", value = ["sourceId", "name", "id"]),
Index(
name = "index_artists_albumCount",
value = ["sourceId", "albumCount", "name", "id"],
value = ["sourceId", "albumCount", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_artists_starred",
value = ["sourceId", "starred", "name", "id"],
value = ["sourceId", "starred", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_artists_frequent",
value = ["sourceId", "playCount", "name", "id"],
value = ["sourceId", "playCount", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_artists_recent",
value = ["sourceId", "played", "name", "id"],
value = ["sourceId", "played", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
],
@@ -82,8 +89,32 @@ data class Artist(
val albumCount: Long,
val starred: Long?,
val coverArt: String? = null,
val artistImageUrl: String? = null,
@ColumnInfo(defaultValue = "''") val coverArts: List<String> = emptyList(),
@ColumnInfo(defaultValue = "''") val downloadedCoverArts: List<String> = emptyList(),
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null,
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
@ColumnInfo(defaultValue = "1") val credited: Boolean = true,
)
@Entity(
tableName = "artist_bios",
primaryKeys = ["sourceId", "artistId"],
foreignKeys = [
ForeignKey(
entity = Source::class,
parentColumns = ["id"],
childColumns = ["sourceId"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class ArtistBio(
val sourceId: Long,
val artistId: String,
val biography: String?,
)
@Entity(
@@ -100,31 +131,33 @@ data class Artist(
indices = [
Index("sourceId"),
Index("sourceId", "artistId"),
Index(name = "index_albums_name", value = ["sourceId", "name", "id"]),
Index(name = "index_albums_artist", value = ["sourceId", "albumArtist", "year", "name", "id"]),
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
Index(name = "index_albums_name_plain", value = ["sourceId", "name", "id"]),
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
Index(name = "index_albums_artist_plain", value = ["sourceId", "albumArtist", "year", "name", "id"]),
Index(
name = "index_albums_year",
value = ["sourceId", "year", "name", "id"],
value = ["sourceId", "year", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_albums_added",
value = ["sourceId", "created", "name", "id"],
value = ["sourceId", "created", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_albums_starred",
value = ["sourceId", "starred", "name", "id"],
value = ["sourceId", "starred", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_albums_frequent",
value = ["sourceId", "playCount", "name", "id"],
value = ["sourceId", "playCount", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_albums_recent",
value = ["sourceId", "played", "name", "id"],
value = ["sourceId", "played", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
],
@@ -144,7 +177,26 @@ data class Album(
@Ignore val discTitles: Map<Long, String> = emptyMap(),
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null,
)
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val albumArtistSort: String = albumArtist.orEmpty(),
) {
val artistRefs: List<ArtistRef>
get() =
artists.ifEmpty {
artistId
?.takeIf { it.isNotBlank() }
?.let { listOf(ArtistRef(it, albumArtist.orEmpty())) }
.orEmpty()
}
}
data class ArtistAlbums(
val albumCredits: List<Album> = emptyList(),
val appearances: List<Album> = emptyList(),
) {
val count: Int get() = albumCredits.size + appearances.size
}
@Entity(
tableName = "discs",
@@ -202,6 +254,8 @@ data class Playlist(
val created: Long,
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
val owner: String? = null,
@ColumnInfo(defaultValue = "0") val shared: Boolean = false,
)
@Entity(
@@ -243,8 +297,8 @@ data class PlaylistSong(
),
Index(
name = "index_songs_starred",
value = ["sourceId", "starred", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC],
value = ["sourceId", "starred", "sortName", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
],
)
@@ -264,8 +318,24 @@ data class Song(
@ColumnInfo(defaultValue = "0") val created: Long = 0,
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null,
val trackGain: Float? = null,
val albumGain: Float? = null,
val trackPeak: Float? = null,
val albumPeak: Float? = null,
@Ignore val genres: List<String> = emptyList(),
)
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = title,
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val artistSort: String = artist.orEmpty(),
) {
val artistRefs: List<ArtistRef>
get() =
artists.ifEmpty {
artistId
?.takeIf { it.isNotBlank() }
?.let { listOf(ArtistRef(it, artist.orEmpty())) }
.orEmpty()
}
}
@Entity(
tableName = "song_genres",
@@ -287,6 +357,48 @@ data class SongGenre(
val genre: String,
)
@Entity(
tableName = "album_artists",
primaryKeys = ["sourceId", "albumId", "position"],
foreignKeys = [
ForeignKey(
entity = Album::class,
parentColumns = ["sourceId", "id"],
childColumns = ["sourceId", "albumId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId", "artistId")],
)
data class AlbumArtistRef(
val sourceId: Long,
val albumId: String,
val position: Long,
val artistId: String,
val name: String,
)
@Entity(
tableName = "song_artists",
primaryKeys = ["sourceId", "songId", "position"],
foreignKeys = [
ForeignKey(
entity = Song::class,
parentColumns = ["sourceId", "id"],
childColumns = ["sourceId", "songId"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId", "artistId")],
)
data class SongArtistRef(
val sourceId: Long,
val songId: String,
val position: Long,
val artistId: String,
val name: String,
)
enum class PendingActionKind { Scrobble, Star, Unstar }
@Entity(
@@ -434,6 +546,8 @@ data class AlbumSearch(
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
val name: String,
val albumArtist: String?,
val sortName: String,
val albumArtistSort: String,
)
@Entity(tableName = "artist_search")
@@ -441,6 +555,7 @@ data class AlbumSearch(
data class ArtistSearch(
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
val name: String,
val sortName: String,
)
@Entity(tableName = "playlist_search")
@@ -456,6 +571,8 @@ data class SongSearch(
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
val title: String,
val artist: String?,
val sortName: String,
val artistSort: String,
)
class QueueKindConverter {
@@ -474,3 +591,53 @@ class PendingActionKindConverter {
fun toPendingActionKind(value: String): PendingActionKind =
PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble
}
class ArtistRefsConverter {
@ColumnTypeConverter
fun fromArtistRefs(refs: List<ArtistRef>): String = ArtistRefsCodec.encode(refs)
@ColumnTypeConverter
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
}
class StringListConverter {
@ColumnTypeConverter
fun fromList(values: List<String>): String = values.joinToString(LIST_SEPARATOR)
@ColumnTypeConverter
fun toList(value: String): List<String> = if (value.isEmpty()) emptyList() else value.split(LIST_SEPARATOR)
}
private const val LIST_SEPARATOR = "\u001F"
object ArtistRefsCodec {
fun encode(refs: List<ArtistRef>): String =
buildString {
refs.forEach { ref ->
append(ref.id.length).append(':').append(ref.id)
append(ref.name.length).append(':').append(ref.name)
}
}
fun decode(value: String): List<ArtistRef> {
val refs = mutableListOf<ArtistRef>()
var index = 0
while (index < value.length) {
val idLengthEnd = value.indexOf(':', index)
if (idLengthEnd < 0) return refs
val idLength = value.substring(index, idLengthEnd).toIntOrNull() ?: return refs
val idStart = idLengthEnd + 1
val idEnd = idStart + idLength
if (idLength < 0 || idEnd > value.length) return refs
val nameLengthEnd = value.indexOf(':', idEnd)
if (nameLengthEnd < 0) return refs
val nameLength = value.substring(idEnd, nameLengthEnd).toIntOrNull() ?: return refs
val nameStart = nameLengthEnd + 1
val nameEnd = nameStart + nameLength
if (nameLength < 0 || nameEnd > value.length) return refs
refs += ArtistRef(value.substring(idStart, idEnd), value.substring(nameStart, nameEnd))
index = nameEnd
}
return refs
}
}
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
@@ -15,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
enum class SortNameMode { SortName, Name, NonLatin }
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
@@ -51,8 +54,13 @@ private val Context.preferences: DataStore<Preferences> by preferencesDataStore(
const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60
const val MIN_REPLAY_GAIN_PREAMP_DB = -15f
const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
enum class SyncMode { Off, WifiOnly, Always }
enum class ReplayGainMode { Off, Track, Album, Auto }
data class ArtworkSeedValue(
val cacheKey: String,
val primary: Int,
@@ -146,6 +154,47 @@ class UserPreferences(
private val scrobblingKey = booleanPreferencesKey("scrobbling")
fun replayGainMode(): Flow<ReplayGainMode> =
store.data.map { prefs ->
prefs[replayGainModeKey]?.let { stored -> ReplayGainMode.entries.firstOrNull { it.name == stored } } ?: ReplayGainMode.Auto
}
suspend fun setReplayGainMode(mode: ReplayGainMode) {
store.edit { prefs -> prefs[replayGainModeKey] = mode.name }
}
private val replayGainModeKey = stringPreferencesKey("replay_gain_mode")
fun replayGainPreampDb(): Flow<Float> = store.data.map { prefs -> prefs[replayGainPreampKey].sanitizedPreampDb() }
suspend fun setReplayGainPreampDb(value: Float) {
store.edit { prefs -> prefs[replayGainPreampKey] = value.sanitizedPreampDb() }
}
private fun Float?.sanitizedPreampDb(): Float =
this?.takeIf { it.isFinite() }?.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB) ?: 0f
private val replayGainPreampKey = floatPreferencesKey("replay_gain_preamp_db")
fun keepScreenOn(): Flow<Boolean> = store.data.map { prefs -> prefs[keepScreenOnKey] ?: false }
suspend fun setKeepScreenOn(enabled: Boolean) {
store.edit { prefs -> prefs[keepScreenOnKey] = enabled }
}
private val keepScreenOnKey = booleanPreferencesKey("keep_screen_on")
fun sortNameMode(): Flow<SortNameMode> =
store.data.map { prefs ->
prefs[sortNameModeKey]?.let { stored -> SortNameMode.entries.firstOrNull { it.name == stored } } ?: SortNameMode.Name
}
suspend fun setSortNameMode(mode: SortNameMode) {
store.edit { prefs -> prefs[sortNameModeKey] = mode.name }
}
private val sortNameModeKey = stringPreferencesKey("sort_name_mode")
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
suspend fun setOfflineMode(enabled: Boolean) {
@@ -11,6 +11,8 @@ import com.subtracks.data.download.EngineDownload
import com.subtracks.data.download.EngineRequest
import com.subtracks.data.download.EngineStatus
import com.subtracks.data.download.looksLikeAudio
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadError
import com.subtracks.data.model.DownloadList
@@ -18,6 +20,7 @@ import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.DownloadedSong
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey
import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException
@@ -43,6 +46,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import java.util.concurrent.ConcurrentHashMap
class DownloadRepository(
private val db: SubtracksDatabase,
@@ -63,6 +67,8 @@ class DownloadRepository(
private var started = false
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
private val failedArtistImages = ConcurrentHashMap.newKeySet<String>()
fun start() {
if (started) return
@@ -347,6 +353,9 @@ class DownloadRepository(
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
group.forEach { file(sourceId, it.songId).delete() }
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
if (group.any { it.status == DownloadStatus.Completed }) {
refreshDownloadedArtistArt(sourceId, group.map { it.songId })
}
}
sweep(db.downloadDao().all())
}
@@ -369,6 +378,7 @@ class DownloadRepository(
.mapNotNull { it.engineId }
.let(engine::cancel)
db.downloadDao().deleteSource(sourceId)
db.libraryDao().clearDownloadedArtistArt(sourceId)
dir(sourceId).deleteRecursively()
}
}
@@ -406,14 +416,18 @@ class DownloadRepository(
private suspend fun fetchArtwork(
sourceId: Long,
songId: String,
artistIds: MutableSet<String>,
) {
val song = db.libraryDao().songOnce(sourceId, songId)
val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) }
val songArtist = song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
val albumArtist = album?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
listOfNotNull(song?.artistId, album?.artistId).forEach(artistIds::add)
listOfNotNull(songArtist, albumArtist).forEach { storeArtistImage(sourceId, it) }
val coverArt =
(
listOfNotNull(
song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it)?.coverArt },
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
listOfNotNull(album?.coverArt) +
db.downloadDao().playlistCoversForSong(sourceId, songId)
).distinct()
coverArt.forEach { cover ->
storeArtwork(sourceId, cover, thumbnail = false)
@@ -421,6 +435,67 @@ class DownloadRepository(
}
}
private suspend fun storeArtistImage(
sourceId: Long,
artist: Artist,
) {
val imageUrl = artist.artistImageUrl
if (!imageUrl.isNullOrBlank()) {
val cacheKey = artistImageKey(sourceId, artist.id)
if (artworkStore.file(sourceId, cacheKey).exists()) return
val attemptKey = "$sourceId:${artist.id}:$imageUrl"
if (attemptKey !in failedArtistImages) {
val bytes =
try {
artworkFetcher.fetch(imageUrl)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
if (bytes != null && bytes.isNotEmpty()) {
artworkStore.write(sourceId, cacheKey, bytes)
return
}
failedArtistImages += attemptKey
}
}
artist.coverArt?.let {
storeArtwork(sourceId, it, thumbnail = false)
storeArtwork(sourceId, it, thumbnail = true)
}
}
private suspend fun fetchArtistBio(
sourceId: Long,
artistId: String,
) {
if (db.libraryDao().artistBioOnce(sourceId, artistId) != null) return
val key = sourceId to artistId
if (!attemptedBios.add(key)) return
try {
val biography = sourceRepository.artistBiography(sourceId, artistId)
db.libraryDao().upsertArtistBio(
ArtistBio(sourceId, artistId, normalizeBiography(biography)),
)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
} finally {
attemptedBios.remove(key)
}
}
private suspend fun fetchArtistBiosSafely(
sourceId: Long,
artistIds: Set<String>,
) {
if (artistIds.isEmpty()) return
withTimeoutOrNull(BIO_TIMEOUT_MS) {
artistIds.forEach { fetchArtistBio(sourceId, it) }
}
}
private suspend fun storeArtwork(
sourceId: Long,
coverArt: String,
@@ -455,11 +530,15 @@ class DownloadRepository(
.mapValues { (sourceId, rows) ->
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
val covers =
rows.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) } +
playlistCovers[sourceId].orEmpty()
covers
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } }
.toSet()
rows.flatMap { row ->
listOfNotNull(row.albumCoverArt, row.artistCoverArt, row.albumArtistCoverArt)
} + playlistCovers[sourceId].orEmpty()
val artistImages = rows.flatMap { row -> listOfNotNull(row.artistId, row.albumArtistId) }
(
covers.flatMap { cover ->
listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) }
} + artistImages.map { artistImageKey(sourceId, it) }
).toSet()
}
}
@@ -509,6 +588,7 @@ class DownloadRepository(
row.engineId?.let { engine.cancel(listOf(it)) }
file(sourceId, songId).delete()
db.downloadDao().delete(sourceId, songId)
if (row.status == DownloadStatus.Completed) refreshDownloadedArtistArt(sourceId, listOf(songId))
return true
}
@@ -537,11 +617,12 @@ class DownloadRepository(
sourceId: Long,
songIds: List<String>,
) {
val artistIds = mutableSetOf<String>()
try {
withTimeoutOrNull(ART_TIMEOUT_MS) {
songIds.forEach { songId ->
// A cancel between the queue and this pass takes the row away; nothing to warm.
if (db.downloadDao().find(sourceId, songId) != null) fetchArtwork(sourceId, songId)
if (db.downloadDao().find(sourceId, songId) != null) fetchArtwork(sourceId, songId, artistIds)
}
}
} catch (e: CancellationException) {
@@ -549,6 +630,7 @@ class DownloadRepository(
} catch (_: Exception) {
// Artwork is best effort and must never disturb a download that is already running.
}
fetchArtistBiosSafely(sourceId, artistIds)
}
private suspend fun apply(
@@ -587,6 +669,11 @@ class DownloadRepository(
},
)
if (updated != row) db.downloadDao().upsert(updated)
if (updated.status != row.status &&
(updated.status == DownloadStatus.Completed || row.status == DownloadStatus.Completed)
) {
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
}
if (updated.status != row.status) {
when (updated.status) {
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
@@ -604,9 +691,22 @@ class DownloadRepository(
row.engineId?.let { engine.cancel(listOf(it)) }
file(row.sourceId, row.songId).delete()
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
if (row.status == DownloadStatus.Completed) {
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
}
Log.w("download", "failed ${row.songId}: $error")
}
private suspend fun refreshDownloadedArtistArt(
sourceId: Long,
songIds: Collection<String>,
) {
songIds.chunked(500).forEach { songs ->
val artistIds = db.libraryDao().creditedArtistIdsForSongs(sourceId, songs).distinct()
artistIds.chunked(500).forEach { db.libraryDao().recomputeDownloadedArtistArt(sourceId, it) }
}
}
private fun ensurePolling() {
if (pollJob?.isActive == true) return
pollJob =
@@ -673,5 +773,6 @@ class DownloadRepository(
const val IN_FLIGHT_LIMIT = 8
const val DELETE_CHUNK = 900
const val ART_TIMEOUT_MS = 60_000L
const val BIO_TIMEOUT_MS = 30_000L
}
}
@@ -14,6 +14,8 @@ import com.subtracks.data.db.rediscoverAlbumsQuery
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.Disc
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSongItem
@@ -22,25 +24,32 @@ import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.source.ServerActionSink
import com.subtracks.data.source.StarType
import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@OptIn(ExperimentalCoroutinesApi::class)
class LibraryRepository(
private val db: SubtracksDatabase,
private val sourceRepository: SourceRepository,
private val serverActions: ServerActionSink,
private val userPreferences: UserPreferences,
private val showMessage: (UiMessage) -> Unit = {},
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
@@ -107,12 +116,18 @@ class LibraryRepository(
fun searchAlbums(
sourceId: Long,
query: String,
): Flow<List<Album>> = db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT)
): Flow<List<Album>> =
userPreferences.sortNameMode().flatMapLatest { mode ->
db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
}
fun searchArtists(
sourceId: Long,
query: String,
): Flow<List<Artist>> = db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT)
): Flow<List<Artist>> =
userPreferences.sortNameMode().flatMapLatest { mode ->
db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
}
fun searchPlaylists(
sourceId: Long,
@@ -122,7 +137,10 @@ class LibraryRepository(
fun searchSongs(
sourceId: Long,
query: String,
): Flow<List<AlbumSongItem>> = db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT)
): Flow<List<AlbumSongItem>> =
userPreferences.sortNameMode().flatMapLatest { mode ->
db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
}
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
@@ -136,9 +154,9 @@ class LibraryRepository(
suspend fun genreSongOrdinal(
sourceId: Long,
genre: String,
title: String,
sortName: String,
id: String,
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, title, id)
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, sortName, id)
suspend fun downloadedSongOrdinal(
sourceId: Long,
@@ -174,66 +192,68 @@ class LibraryRepository(
search: String,
downloaded: Boolean,
): Flow<PagingData<Album>> =
pager(40) {
val dao = db.libraryDao()
if (!downloaded) {
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
} else {
val downloadedFilter = 1
when (sort) {
AlbumSort.Name -> {
if (descending) {
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter)
userPreferences.sortNameMode().flatMapLatest { mode ->
pager(40, enablePlaceholders = true) {
val dao = db.libraryDao()
if (!downloaded) {
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
} else {
val downloadedFilter = 1
when (sort) {
AlbumSort.Name -> {
if (descending) {
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
} else {
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
}
}
}
AlbumSort.Artist -> {
if (descending) {
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter)
AlbumSort.Artist -> {
if (descending) {
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
} else {
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
}
}
}
AlbumSort.Year -> {
if (descending) {
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
AlbumSort.Year -> {
if (descending) {
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
}
}
}
AlbumSort.Added -> {
if (descending) {
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
AlbumSort.Added -> {
if (descending) {
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
}
}
}
AlbumSort.Starred -> {
if (descending) {
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
AlbumSort.Starred -> {
if (descending) {
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
}
}
}
AlbumSort.Frequent -> {
if (descending) {
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
AlbumSort.Frequent -> {
if (descending) {
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
}
}
}
AlbumSort.Recent -> {
if (descending) {
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
AlbumSort.Recent -> {
if (descending) {
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
}
}
}
}
@@ -248,50 +268,52 @@ class LibraryRepository(
search: String,
downloaded: Boolean,
): Flow<PagingData<Artist>> =
pager(60) {
val dao = db.libraryDao()
if (!downloaded) {
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
} else {
val downloadedFilter = 1
when (sort) {
ArtistSort.Name -> {
if (descending) {
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter)
userPreferences.sortNameMode().flatMapLatest { mode ->
pager(60) {
val dao = db.libraryDao()
if (!downloaded) {
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
} else {
val downloadedFilter = 1
when (sort) {
ArtistSort.Name -> {
if (descending) {
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
} else {
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
}
}
}
ArtistSort.AlbumCount -> {
if (descending) {
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
ArtistSort.AlbumCount -> {
if (descending) {
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
}
}
}
ArtistSort.Starred -> {
if (descending) {
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
ArtistSort.Starred -> {
if (descending) {
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
}
}
}
ArtistSort.Frequent -> {
if (descending) {
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
ArtistSort.Frequent -> {
if (descending) {
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
}
}
}
ArtistSort.Recent -> {
if (descending) {
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
ArtistSort.Recent -> {
if (descending) {
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
}
}
}
}
@@ -354,6 +376,36 @@ class LibraryRepository(
artistId: String,
): Flow<String?> = db.libraryDao().artistName(sourceId, artistId)
fun artistBio(
sourceId: Long,
artistId: String,
): Flow<ArtistBio?> =
db
.libraryDao()
.artistBio(sourceId, artistId)
.map { bio -> bio?.copy(biography = normalizeBiography(bio.biography)) }
suspend fun refreshArtistBio(
sourceId: Long,
artistId: String,
): Result<Unit> =
try {
val fetched = normalizeBiography(sourceRepository.artistBiography(sourceId, artistId))
val current = db.libraryDao().artistBioOnce(sourceId, artistId)
// An empty response can mean the metadata provider is temporarily down, so keep the
// cached biography instead of erasing it.
val biography = fetched ?: current?.biography
if (current == null || current.biography != biography) {
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, artistId, biography))
}
Result.success(Unit)
} catch (cancellation: CancellationException) {
throw cancellation
} catch (failure: Exception) {
Log.w("artist", "failed to refresh bio for $artistId", failure)
Result.failure(failure)
}
fun song(
sourceId: Long,
songId: String,
@@ -376,12 +428,24 @@ class LibraryRepository(
sourceId: Long,
artistId: String,
downloaded: Boolean = false,
): Flow<List<Album>> =
if (downloaded) {
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
} else {
db.libraryDao().albumsForArtist(sourceId, artistId)
): Flow<ArtistAlbums> {
val albums =
if (downloaded) {
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
} else {
db.libraryDao().albumsForArtist(sourceId, artistId)
}
return combine(albums, db.libraryDao().albumCreditAlbumIds(sourceId, artistId)) { list, creditIds ->
val credits = creditIds.toHashSet()
val (albumCredits, appearances) = list.partition { it.id in credits }
ArtistAlbums(albumCredits = albumCredits, appearances = appearances)
}
}
fun artistCoverArts(
sourceId: Long,
artistId: String,
): Flow<List<String>> = db.libraryDao().artistCoverArts(sourceId, artistId)
fun albumDiscs(
sourceId: Long,
@@ -507,10 +571,11 @@ class LibraryRepository(
private fun <T : Any> pager(
pageSize: Int,
enablePlaceholders: Boolean = false,
source: () -> PagingSource<Int, T>,
): Flow<PagingData<T>> =
Pager(
config = PagingConfig(pageSize = pageSize, enablePlaceholders = false),
config = PagingConfig(pageSize = pageSize, enablePlaceholders = enablePlaceholders),
pagingSourceFactory = source,
).flow
}
@@ -519,4 +584,12 @@ const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000
const val SEARCH_RESULT_LIMIT = 50
internal fun normalizeBiography(value: String?): String? =
value
?.replace("\r\n", "\n")
?.replace('\r', '\n')
?.replace(Regex(" {3,}"), "\n\n")
?.trim()
?.takeIf { it.isNotEmpty() }
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
@@ -164,6 +164,8 @@ data class QueueSnapshot(
}
fun locateContext(contextPlay: Long): Pair<QueueEntry, Long>? = locateIn(entries, flatContext(contextPlay) ?: return null)
fun isAlbumContext(position: Long): Boolean = locate(position)?.first?.kind == QueueKind.Album
}
private fun entryIndexAfter(
@@ -295,8 +297,8 @@ class QueueRepository(
?.takeIf { it.isNotBlank() }
}
// An explicit song entry in the middle of a queue is a reordered context track, so
// label it by its album rather than losing the context name.
// A song entry can be a single or a track reordered out of a playlist, artist or
// genre, so label it by its album rather than losing the context name.
QueueKind.Song -> {
library
.song(sourceId, refId)
@@ -581,9 +583,9 @@ class QueueRepository(
val toUpNext = snapshot.isUpNext(to)
if (fromUpNext && toUpNext) {
val song = itemAt(snapshot, from) ?: return false
val sourceId = snapshot.locate(from)?.first?.sourceId ?: return false
val located = snapshot.locate(from) ?: return false
val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
val moved = ResolvedQueueEntry(songEntry(sourceId, song.song.id), 1L)
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
return true
}
@@ -597,7 +599,7 @@ class QueueRepository(
val located = snapshot.locateContext(playFrom) ?: return false
val song = itemAt(snapshot, from) ?: return false
val removed = removeEntry(snapshot.entries, playFrom)
val moved = ResolvedQueueEntry(songEntry(located.first.sourceId, song.song.id), 1L)
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
val insertAt = playTo.coerceIn(0, removed.sumOf { it.length })
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
adjustAnchorOnMove(playFrom, insertAt)
@@ -929,6 +931,18 @@ class QueueRepository(
return count?.coerceAtMost(available) ?: available
}
private fun movedEntry(
source: QueueEntry,
songId: String,
offsetInEntry: Long,
): QueueEntry =
if (source.kind == QueueKind.Album) {
val offset = source.offset + offsetInEntry
source.range(offset, offset)
} else {
songEntry(source.sourceId, songId)
}
private fun QueueEntry.range(
start: Long,
end: Long,
@@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicConfig
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.StreamQuality
@@ -69,6 +70,9 @@ class SourceRepository(
private val _offline = MutableStateFlow(false)
val offline: StateFlow<Boolean> = _offline
private val _activeSource = MutableStateFlow<Long?>(null)
val activeSource: StateFlow<Long?> = _activeSource
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
fun setOfflineMode(enabled: Boolean) {
@@ -109,6 +113,7 @@ class SourceRepository(
Log.i("net", "active source -> ${config?.name ?: "none"}")
}
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
_activeSource.value = config?.id
_quality.value = quality
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
scope.launch {
@@ -143,6 +148,18 @@ class SourceRepository(
return CoverArtRef(url = url, cacheKey = cacheKey)
}
fun artistImage(
artistId: String,
url: String?,
): CoverArtRef? {
val current = active ?: return null
if (url.isNullOrBlank()) return null
val cacheKey = artistImageKey(current.id, artistId)
artworkStore.uri(current.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
if (_offline.value) return null
return CoverArtRef(url = url, cacheKey = cacheKey)
}
fun networkCoverArt(
sourceId: Long,
coverArt: String,
@@ -220,8 +237,8 @@ class SourceRepository(
useTokenAuth: Boolean,
) {
val dao = db.sourcesDao()
val source = dao.sourceOnce(id) ?: return
dao.upsertSource(source.copy(name = name.trim(), address = normalizeAddress(address)))
if (dao.sourceOnce(id) == null) return
dao.updateSourceDetails(id, name.trim(), normalizeAddress(address))
dao.upsertSubsonicSource(
SubsonicSource(
sourceId = id,
@@ -261,6 +278,15 @@ class SourceRepository(
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
}
suspend fun artistBiography(
sourceId: Long,
artistId: String,
): String? {
check(!_offline.value) { "Offline mode is on" }
val config = db.sourcesDao().subsonicConfigOnce(sourceId) ?: error("No configured source")
return config.toMusicSource(quality.value, prefs.syncConcurrency().first()).artistBiography(artistId)
}
private fun SubsonicConfig.toClient(): SubsonicClient =
client(
address = address,
@@ -26,6 +26,8 @@ interface MusicSource {
time: Long? = null,
)
suspend fun artistBiography(artistId: String): String? = null
fun artists(): Flow<List<Artist>>
fun albums(): Flow<List<Album>>
@@ -8,6 +8,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.InputStream
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
open class SubsonicException(
val code: Int,
@@ -55,11 +56,12 @@ class SubsonicClient(
fun <T> stream(
method: String,
params: Map<String, String> = emptyMap(),
timeoutMs: Long? = null,
body: (InputStream) -> T,
): T {
val usedTokenAuth = useTokenAuth
return try {
execute(method, params, body)
execute(method, params, timeoutMs, body)
} catch (failure: SubsonicException) {
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) {
Log.w("http", "$method failed: server code ${failure.code} \"${failure.message}\"")
@@ -67,13 +69,14 @@ class SubsonicClient(
}
Log.w("http", "$method: token auth unsupported (server code ${failure.code}), retrying with plaintext")
disableTokenAuth()
execute(method, params, body)
execute(method, params, timeoutMs, body)
}
}
private fun <T> execute(
method: String,
params: Map<String, String>,
timeoutMs: Long?,
body: (InputStream) -> T,
): T {
val request =
@@ -81,7 +84,9 @@ class SubsonicClient(
.Builder()
.url(uri(method, params))
.build()
http.newCall(request).execute().use { response ->
val call = http.newCall(request)
timeoutMs?.let { call.timeout().timeout(it, TimeUnit.MILLISECONDS) }
call.execute().use { response ->
if (!response.isSuccessful) {
throw SubsonicException(-1, "HTTP ${response.code}", UiMessage(R.string.error_http, listOf(response.code)))
}
@@ -25,7 +25,9 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.xml.sax.Attributes
@@ -83,6 +85,13 @@ class SubsonicSource(
}
}
override suspend fun artistBiography(artistId: String): String? =
withContext(Dispatchers.IO) {
client.stream("getArtistInfo2", mapOf("id" to artistId), timeoutMs = ARTIST_BIO_TIMEOUT_MS) { input ->
SubsonicXml.artistBiography(input)
}
}
override fun artists(): Flow<List<Artist>> =
entityBatches(
method = "getArtists",
@@ -101,7 +110,7 @@ class SubsonicSource(
params = page("newest", offset),
tag = "album",
create = { SubsonicXml.albumDraft(id, it) },
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
onChild = SubsonicXml::albumChild,
).collect { batch ->
raw += batch.size
val accepted = batch.filter { it.id.isNotEmpty() }
@@ -205,7 +214,7 @@ class SubsonicSource(
),
tag = "song",
create = { SubsonicXml.songDraft(id, it) },
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
onChild = SubsonicXml::songChild,
).collect { batch ->
raw += batch.size
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
@@ -224,6 +233,23 @@ class SubsonicSource(
private fun albumSongs(): Flow<List<Song>> =
channelFlow {
val permits = Semaphore(maxConcurrentFetches)
val lock = Mutex()
val pending = ArrayList<Song>(PAGE_SIZE)
suspend fun accept(songs: List<Song>) {
lock
.withLock {
pending.addAll(songs)
if (pending.size < PAGE_SIZE) {
null
} else {
val batch = ArrayList(pending.subList(0, PAGE_SIZE))
pending.subList(0, PAGE_SIZE).clear()
batch
}
}?.let { send(it) }
}
var offset = 0
var pages = 0
while (true) {
@@ -248,10 +274,10 @@ class SubsonicSource(
params = mapOf("id" to albumId),
tag = "song",
create = { SubsonicXml.songDraft(id, it) },
onChild = { song, name, attrs -> if (name == "genres") song.addGenre(attrs) },
onChild = SubsonicXml::songChild,
).collect { batch ->
val accepted = batch.map { it.toSong() }.filter { it.id.isNotEmpty() }
if (accepted.isNotEmpty()) send(accepted)
accept(accepted)
}
}
}
@@ -265,7 +291,14 @@ class SubsonicSource(
if (pages > maxPages) throw pageCapExceeded()
offset += PAGE_SIZE
}
}
val tail =
lock.withLock {
val held = pending.toList()
pending.clear()
held
}
if (tail.isNotEmpty()) send(tail)
}.buffer(1)
private fun supportsEmptyQuerySearch(): Boolean {
emptyQuerySearchSupported?.let { return it }
@@ -297,7 +330,7 @@ class SubsonicSource(
params: Map<String, String> = emptyMap(),
tag: String,
create: (Attributes) -> T,
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
): Flow<List<T>> =
channelFlow {
val buffer = ArrayList<T>(PAGE_SIZE)
@@ -339,6 +372,7 @@ class SubsonicSource(
const val PAGE_SIZE = 500
const val MAX_PAGES = 20_000
const val THUMBNAIL_SIZE = 384
const val ARTIST_BIO_TIMEOUT_MS = 15_000L
}
}
@@ -4,6 +4,7 @@ import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
@@ -31,6 +32,8 @@ object SubsonicXml {
albumCount = attrs.longAttr("albumCount") ?: 0L,
starred = attrs.dateAttr("starred"),
coverArt = attrs.attr("coverArt").ifEmpty { null },
artistImageUrl = attrs.attr("artistImageUrl").ifEmpty { null },
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
)
internal fun albumDraft(
@@ -43,6 +46,8 @@ object SubsonicXml {
artistId = attrs.attr("artistId").ifEmpty { null },
name = attrs.attr("name"),
albumArtist = attrs.attr("artist").ifEmpty { null },
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
sortName = attrs.attr("sortName").ifEmpty { null },
created = attrs.dateAttr("created") ?: 0L,
coverArt = attrs.attr("coverArt").ifEmpty { null },
genre = attrs.attr("genre").ifEmpty { null },
@@ -68,6 +73,8 @@ object SubsonicXml {
title = attrs.attr("title"),
album = attrs.attr("album").ifEmpty { null },
artist = attrs.attr("artist").ifEmpty { null },
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
sortName = attrs.attr("sortName").ifEmpty { null },
duration = attrs.longAttr("duration"),
track = attrs.longAttr("track"),
disc = attrs.longAttr("discNumber"),
@@ -78,6 +85,36 @@ object SubsonicXml {
played = attrs.dateAttr("played"),
)
internal fun songChild(
entity: SongDraft,
name: String,
attrs: Attributes,
level: Int,
) {
when (name) {
"genres" -> if (level == 1) entity.addGenre(attrs)
"replayGain" -> if (level == 1) entity.addReplayGain(attrs)
"artists" -> if (level == 1) entity.addArtists(attrs)
"albumArtists" -> entity.clearNestedArtists()
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
else -> if (level == 1) entity.clearNestedArtists()
}
}
internal fun albumChild(
entity: AlbumDraft,
name: String,
attrs: Attributes,
level: Int,
) {
when (name) {
"discTitles" -> if (level == 1) entity.addDiscTitle(attrs)
"artists" -> if (level == 1) entity.addArtists(attrs)
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
else -> if (level == 1) entity.clearArtists()
}
}
internal fun playlist(
sourceId: Long,
attrs: Attributes,
@@ -92,6 +129,8 @@ object SubsonicXml {
created = attrs.dateAttr("created") ?: 0L,
changed = attrs.dateAttr("changed") ?: 0L,
duration = attrs.longAttr("duration") ?: 0L,
owner = attrs.attr("owner").ifEmpty { null },
shared = attrs.boolAttr("public"),
)
internal fun playlistSong(
@@ -115,7 +154,7 @@ object SubsonicXml {
input: InputStream,
entityTag: String,
create: (Attributes) -> T,
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
onEnd: (T) -> Unit,
) {
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
@@ -125,10 +164,16 @@ object SubsonicXml {
input: InputStream,
entityTag: String,
): Boolean {
val handler = EntityHandler(entityTag, { Unit }, { _, _, _ -> }, { }, stopAfterFirst = true)
val handler = EntityHandler(entityTag, { Unit }, { _, _, _, _ -> }, { }, stopAfterFirst = true)
parse(input, handler)
return handler.found
}
internal fun artistBiography(input: InputStream): String? {
val handler = ArtistBiographyHandler()
parse(input, handler)
return handler.biography
}
}
internal class AlbumDraft(
@@ -137,6 +182,8 @@ internal class AlbumDraft(
var artistId: String? = null,
var name: String = "",
var albumArtist: String? = null,
var displayArtist: String? = null,
var sortName: String? = null,
var created: Long = 0L,
var coverArt: String? = null,
var genre: String? = null,
@@ -145,19 +192,41 @@ internal class AlbumDraft(
var songCount: Long = 0L,
val discTitles: MutableMap<Long, String> = mutableMapOf(),
) {
private val artists = mutableListOf<ArtistRef>()
private var artistContainer = false
fun addDiscTitle(attrs: Attributes) {
val disc = attrs.longAttr("disc") ?: return
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
discTitles[disc] = title
}
fun toAlbum(): Album =
Album(
fun addArtists(attrs: Attributes) {
val ref = attrs.artistRef()
if (ref == null) {
artistContainer = true
} else {
artists += ref
artistContainer = false
}
}
fun addNestedArtist(attrs: Attributes) {
if (artistContainer) attrs.artistRef()?.let { artists += it }
}
fun clearArtists() {
artistContainer = false
}
fun toAlbum(): Album {
val refs = artistFallback(artists, artistId, albumArtist)
return Album(
sourceId = sourceId,
id = id,
artistId = artistId,
name = name,
albumArtist = albumArtist,
albumArtist = displayName(albumArtist, displayArtist, refs),
created = created,
coverArt = coverArt,
genre = genre,
@@ -165,7 +234,10 @@ internal class AlbumDraft(
starred = starred,
songCount = songCount,
discTitles = discTitles.toMap(),
artists = refs,
sortName = sortName?.takeIf { it.isNotBlank() } ?: name,
)
}
}
internal class SongDraft(
@@ -176,6 +248,8 @@ internal class SongDraft(
private val title: String = "",
private val album: String? = null,
private val artist: String? = null,
private val displayArtist: String? = null,
private val sortName: String? = null,
private val duration: Long? = null,
private val track: Long? = null,
private val disc: Long? = null,
@@ -184,8 +258,15 @@ internal class SongDraft(
private val created: Long = 0L,
private val playCount: Long = 0L,
private val played: Long? = null,
private var trackGain: Float? = null,
private var albumGain: Float? = null,
private var trackPeak: Float? = null,
private var albumPeak: Float? = null,
private val genres: MutableList<String> = mutableListOf(),
) {
private val artists = mutableListOf<ArtistRef>()
private var nestedArtists: MutableList<ArtistRef>? = null
fun addGenre(attrs: Attributes) {
attrs
.attr("name")
@@ -194,15 +275,41 @@ internal class SongDraft(
?.let { genres += it }
}
fun toSong(): Song =
Song(
fun addReplayGain(attrs: Attributes) {
attrs.floatAttr("trackGain")?.let { trackGain = it }
attrs.floatAttr("albumGain")?.let { albumGain = it }
attrs.floatAttr("trackPeak")?.let { trackPeak = it }
attrs.floatAttr("albumPeak")?.let { albumPeak = it }
}
fun addArtists(attrs: Attributes) {
val ref = attrs.artistRef()
if (ref == null) {
nestedArtists = artists
} else {
artists += ref
nestedArtists = null
}
}
fun clearNestedArtists() {
nestedArtists = null
}
fun addNestedArtist(attrs: Attributes) {
nestedArtists?.let { list -> attrs.artistRef()?.let { list += it } }
}
fun toSong(): Song {
val refs = artistFallback(artists, artistId, artist)
return Song(
sourceId = sourceId,
id = id,
albumId = albumId,
artistId = artistId,
title = title,
album = album,
artist = artist,
artist = displayName(artist, displayArtist, refs),
duration = duration,
track = track,
disc = disc,
@@ -211,14 +318,59 @@ internal class SongDraft(
created = created,
playCount = playCount,
played = played,
trackGain = trackGain,
albumGain = albumGain,
trackPeak = trackPeak,
albumPeak = albumPeak,
genres = genres.toList(),
artists = refs,
sortName = sortName?.takeIf { it.isNotBlank() } ?: title,
)
}
}
private fun Attributes.artistRef(): ArtistRef? {
val id = attr("id")
val name = attr("name")
if (id.isEmpty() && name.isEmpty()) return null
return ArtistRef(id, name)
}
private fun artistFallback(
parsed: List<ArtistRef>,
artistId: String?,
name: String?,
): List<ArtistRef> {
val refs = parsed.filter { it.id.isNotBlank() }.distinctBy { it.id }
if (refs.isNotEmpty()) return refs
if (artistId.isNullOrBlank()) return emptyList()
return listOf(ArtistRef(artistId, name.orEmpty()))
}
private const val ARTIST_JOINER = " • "
private fun displayName(
legacy: String?,
openSubsonic: String?,
refs: List<ArtistRef>,
): String? =
legacy?.takeIf { it.isNotBlank() }
?: openSubsonic?.takeIf { it.isNotBlank() }
?: refs
.map { it.name }
.filter { it.isNotBlank() }
.distinct()
.joinToString(ARTIST_JOINER)
.takeIf { it.isNotEmpty() }
internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
internal fun Attributes.boolAttr(name: String): Boolean = attr(name).let { it == "1" || it.equals("true", ignoreCase = true) }
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() }
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
private const val ROOT_TAG = "subsonic-response"
@@ -280,10 +432,44 @@ private open class SubsonicResponseHandler : DefaultHandler() {
) = Unit
}
private class ArtistBiographyHandler : SubsonicResponseHandler() {
private var biographyDepth = -1
private val text = StringBuilder()
var biography: String? = null
private set
override fun characters(
ch: CharArray,
start: Int,
length: Int,
) {
if (biographyDepth >= 0) text.append(ch, start, length)
}
override fun onElementStart(
name: String,
attributes: Attributes,
depth: Int,
) {
if (name == "biography" && biographyDepth < 0) biographyDepth = depth
}
override fun onElementEnd(
name: String,
depth: Int,
) {
if (name == "biography" && depth == biographyDepth) {
biography = text.toString().trim().takeIf { it.isNotEmpty() }
biographyDepth = -1
}
}
}
private class EntityHandler<T>(
private val entityTag: String,
private val create: (Attributes) -> T,
private val onChild: (T, String, Attributes) -> Unit,
private val onChild: (T, String, Attributes, Int) -> Unit,
private val onEnd: (T) -> Unit,
private val stopAfterFirst: Boolean,
) : SubsonicResponseHandler() {
@@ -304,8 +490,8 @@ private class EntityHandler<T>(
current = create(attributes)
entityDepth = depth
}
} else if (depth == entityDepth + 1) {
onChild(active, name, attributes)
} else if (depth > entityDepth) {
onChild(active, name, attributes, depth - entityDepth)
}
}
@@ -6,12 +6,16 @@ import androidx.room3.useWriterConnection
import androidx.sqlite.SQLiteStatement
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumArtistRef
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.ArtistRefsCodec
import com.subtracks.data.model.Disc
import com.subtracks.data.model.DiscKey
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongArtistRef
import com.subtracks.data.model.SongGenre
import com.subtracks.data.source.MusicSource
import java.util.Arrays
@@ -28,10 +32,16 @@ class SyncService(
private var pruned = 0
suspend fun sync(): SyncSummary {
syncArtists()
syncAlbums()
syncSongs()
aggregatePlayData()
val sources = db.sourcesDao()
val wasStale = sources.creditsStale(source.id) ?: true
val artistsChanged = syncArtists()
val albumsChanged = syncAlbums()
val songsChanged = syncSongs()
optimize()
if (artistsChanged || albumsChanged || songsChanged || wasStale) {
recomputeAggregates()
sources.setCreditsStale(source.id, false)
}
syncPlaylists()
syncPlaylistSongs()
return SyncSummary(
@@ -44,61 +54,90 @@ class SyncService(
)
}
private suspend fun syncArtists() {
private suspend fun syncArtists(): Boolean {
val library = db.libraryDao()
val seen = HashedIds()
var changed = false
source.artists().collect { batch ->
if (batch.isEmpty()) return@collect
write("artists", artistColumns, primaryKey, batch) { it.values() }
changed =
write("artists", artistColumns, primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.values() } ||
changed
batch.forEach { seen.add(idHash(it.id)) }
artistsSynced += batch.size
}
val prunedBefore = pruned
pruned += pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
return changed || pruned > prunedBefore
}
private suspend fun syncAlbums() {
private suspend fun syncAlbums(): Boolean {
val library = db.libraryDao()
val seenAlbums = HashedIds()
val seenDiscs = HashedIds()
var changed = false
source.albums().collect { batch ->
if (batch.isEmpty()) return@collect
val discs =
batch.flatMap { album ->
album.discTitles.map { (disc, title) -> Disc(album.sourceId, album.id, disc, title) }
}
writeAlbumsAndDiscs(batch, discs)
changed = writeAlbumsAndDiscs(batch, discs) || changed
batch.forEach { seenAlbums.add(idHash(it.id)) }
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
albumsSynced += batch.size
}
pruned += pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
val prunedBefore = pruned
pruned +=
pruneStaleIds(
seenAlbums,
{ library.albumIdsAfter(source.id, it, PRUNE_PAGE) },
{ library.deleteAlbums(source.id, it) },
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
)
pruned +=
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
deleteStaleDiscs(it)
}
return changed || pruned > prunedBefore
}
private suspend fun syncSongs() {
private suspend fun syncSongs(): Boolean {
val library = db.libraryDao()
val seen = HashedIds()
var changed = false
source.songs().collect { batch ->
if (batch.isEmpty()) return@collect
writeSongs(batch)
changed = writeSongs(batch) || changed
batch.forEach { seen.add(idHash(it.id)) }
songsSynced += batch.size
}
pruned += pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
val prunedBefore = pruned
pruned +=
pruneStaleIds(
seen,
{ library.songIdsAfter(source.id, it, PRUNE_PAGE) },
{ library.deleteSongs(source.id, it) },
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
)
return changed || pruned > prunedBefore
}
private suspend fun writeSongs(batch: List<Song>) {
private suspend fun writeSongs(batch: List<Song>): Boolean {
val names = batch.associate { it.id to it.genreNames() }
val genres =
batch.flatMap { song ->
names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) }
}
db.useWriterConnection { connection ->
val artists =
batch.flatMap { song ->
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
}
return db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.upsertChanged("songs", songColumns, primaryKey, batch) { it.values() }
val before = connection.totalChanges()
val resolved = connection.resolveSongSortNames(batch)
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
batch.forEach { song ->
statement.bind(1, song.sourceId)
@@ -110,12 +149,106 @@ class SyncService(
}
}
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
batch.forEach { song ->
statement.bind(1, song.sourceId)
statement.bind(2, song.id)
statement.bind(3, song.artistPositions().size.toLong())
statement.step()
statement.reset()
statement.clearBindings()
}
}
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
(connection.totalChanges() != before).also { changed ->
if (changed) connection.markCreditsStale(batch.first().sourceId)
}
}
}
}
private suspend fun aggregatePlayData() {
db.libraryDao().recomputePlayData(source.id)
private suspend fun recomputeAggregates() {
db.libraryDao().recomputeAggregates(source.id)
}
private suspend fun PooledConnection.totalChanges(): Long {
var value = 0L
usePrepared("SELECT total_changes()") { statement ->
statement.step()
value = statement.getLong(0)
}
return value
}
private suspend fun PooledConnection.markCreditsStale(sourceId: Long) {
usePrepared("UPDATE sources SET creditsStale = 1 WHERE id = ? AND creditsStale = 0") { statement ->
statement.bindLong(1, sourceId)
statement.step()
}
}
private suspend fun PooledConnection.resolveSongSortNames(batch: List<Song>): List<Song> {
if (batch.isEmpty()) return batch
val sortNames =
artistSortNames(
batch.first().sourceId,
batch
.flatMap { listOfNotNull(it.artistId) + it.artistRefs.map { ref -> ref.id } }
.filter { it.isNotBlank() }
.toSet(),
)
return batch.map { song ->
val primary = song.artistId ?: song.artists.firstOrNull()?.id
val sortName =
primary?.let(sortNames::get)?.takeIf { it.isNotBlank() }
?: song.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } }
?: song.artistSort
if (sortName == song.artistSort) song else song.copy(artistSort = sortName)
}
}
private suspend fun PooledConnection.resolveAlbumSortNames(batch: List<Album>): List<Album> {
if (batch.isEmpty()) return batch
val sortNames =
artistSortNames(
batch.first().sourceId,
batch
.flatMap { listOfNotNull(it.artistId) + it.artistRefs.map { ref -> ref.id } }
.filter { it.isNotBlank() }
.toSet(),
)
return batch.map { album ->
val primary = album.artistId ?: album.artists.firstOrNull()?.id
val sortName =
primary?.let(sortNames::get)?.takeIf { it.isNotBlank() }
?: album.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } }
?: album.albumArtistSort
if (sortName == album.albumArtistSort) album else album.copy(albumArtistSort = sortName)
}
}
private suspend fun PooledConnection.artistSortNames(
sourceId: Long,
ids: Collection<String>,
): Map<String, String> {
if (ids.isEmpty()) return emptyMap()
val sortNames = HashMap<String, String>(ids.size)
ids.toList().chunked(SQL_VARIABLES).forEach { chunk ->
val placeholders = chunk.joinToString(",") { "?" }
usePrepared("SELECT id, sortName FROM artists WHERE sourceId = ? AND id IN ($placeholders)") { statement ->
statement.bindLong(1, sourceId)
chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) }
while (statement.step()) sortNames[statement.getText(0)] = statement.getText(1)
}
}
return sortNames
}
// Without sqlite_stat1 the artist and relation queries can fall back to whole-table scans.
private suspend fun optimize() {
db.useWriterConnection { connection ->
connection.usePrepared("PRAGMA optimize") { it.step() }
}
}
private suspend fun syncPlaylists() {
@@ -150,23 +283,48 @@ class SyncService(
columns: List<String>,
key: List<String>,
rows: List<T>,
preserved: Set<String> = emptySet(),
markStale: Boolean = false,
values: (T) -> List<Any?>,
) {
): Boolean =
db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.upsertChanged(table, columns, key, rows, values)
val before = connection.totalChanges()
connection.upsertChanged(table, columns, key, rows, preserved, values)
(connection.totalChanges() != before).also { changed ->
if (changed && markStale) connection.markCreditsStale(source.id)
}
}
}
}
private suspend fun writeAlbumsAndDiscs(
albums: List<Album>,
discs: List<Disc>,
) {
db.useWriterConnection { connection ->
): Boolean {
val artists =
albums.flatMap { album ->
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
}
return db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
val before = connection.totalChanges()
val resolved = connection.resolveAlbumSortNames(albums)
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
albums.forEach { album ->
statement.bind(1, album.sourceId)
statement.bind(2, album.id)
statement.bind(3, album.artistPositions().size.toLong())
statement.step()
statement.reset()
statement.clearBindings()
}
}
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
(connection.totalChanges() != before).also { changed ->
if (changed) connection.markCreditsStale(albums.first().sourceId)
}
}
}
}
@@ -194,6 +352,7 @@ class SyncService(
seen: HashedIds,
pageOf: suspend (afterId: String) -> List<String>,
delete: suspend (Collection<String>) -> Unit,
markStale: (suspend () -> Unit)? = null,
): Int {
seen.freeze()
var afterId = ""
@@ -202,6 +361,7 @@ class SyncService(
val ids = pageOf(afterId)
if (ids.isEmpty()) return pruned
val stale = ids.filterNot { seen.contains(idHash(it)) }
if (stale.isNotEmpty()) markStale?.invoke()
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
pruned += stale.size
if (ids.size < PRUNE_PAGE) return pruned
@@ -241,6 +401,7 @@ data class SyncSummary(
)
private const val DELETE_CHUNK = 500
private const val SQL_VARIABLES = 500
private const val PRUNE_PAGE = 500
private const val INITIAL_HASH_CAPACITY = 256
private const val FNV_OFFSET = -3750763034362895579L
@@ -277,7 +438,7 @@ private fun discHash(
private val primaryKey = listOf("sourceId", "id")
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt")
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "artistImageUrl", "sortName")
private val albumColumns =
listOf(
@@ -292,6 +453,9 @@ private val albumColumns =
"year",
"starred",
"songCount",
"artists",
"sortName",
"albumArtistSort",
)
private val discKey = listOf("sourceId", "albumId", "disc")
@@ -314,9 +478,17 @@ private val songColumns =
"created",
"playCount",
"played",
"trackGain",
"albumGain",
"trackPeak",
"albumPeak",
"artists",
"sortName",
"artistSort",
)
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
private val playlistColumns =
listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration", "owner", "shared")
private fun Song.genreNames(): List<String> =
genres
@@ -332,13 +504,41 @@ private val songGenreColumns = listOf("sourceId", "songId", "position", "genre")
private val songGenreKey = listOf("sourceId", "songId", "position")
private val albumArtistColumns = listOf("sourceId", "albumId", "position", "artistId", "name")
private val albumArtistKey = listOf("sourceId", "albumId", "position")
private val songArtistColumns = listOf("sourceId", "songId", "position", "artistId", "name")
private val songArtistKey = listOf("sourceId", "songId", "position")
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt)
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, artistImageUrl, sortName)
private fun Album.values() = listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount)
private fun Album.values() =
listOf<Any?>(
sourceId,
id,
artistId,
name,
albumArtist,
created,
coverArt,
genre,
year,
starred,
songCount,
ArtistRefsCodec.encode(artistRefs),
sortName,
albumArtistSort,
)
private fun AlbumArtistRef.values() = listOf<Any?>(sourceId, albumId, position, artistId, name)
private fun SongArtistRef.values() = listOf<Any?>(sourceId, songId, position, artistId, name)
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
@@ -358,11 +558,27 @@ private fun Song.values() =
created,
playCount,
played,
trackGain,
albumGain,
trackPeak,
albumPeak,
ArtistRefsCodec.encode(artistRefs),
sortName,
artistSort,
)
private fun List<ArtistRef>.positions(): List<Pair<Long, ArtistRef>> =
filter { it.id.isNotBlank() }
.distinctBy { it.id }
.mapIndexed { index, ref -> index.toLong() to ref }
private fun Album.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration, owner, shared)
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
@@ -371,10 +587,12 @@ private suspend fun <T> PooledConnection.upsertChanged(
columns: List<String>,
key: List<String>,
rows: List<T>,
preserved: Set<String> = emptySet(),
values: (T) -> List<Any?>,
) {
if (rows.isEmpty()) return
val updates = columns.filterNot { it in key }
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
val updates = columns.filterNot { it in key || it in preserved }
val sql =
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
@@ -401,6 +619,7 @@ private fun SQLiteStatement.bind(
is Int -> bindLong(index, value.toLong())
is Boolean -> bindLong(index, if (value) 1L else 0L)
is Double -> bindDouble(index, value)
is Float -> bindDouble(index, value.toDouble())
is String -> bindText(index, value)
else -> error("Cannot bind ${value::class}")
}
@@ -96,7 +96,7 @@ fun appModule(
scope = get(),
)
}
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
single { LibraryRepository(get(), get(), get(), get(), toast, scope = get(named("io"))) }
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
single {
@@ -0,0 +1,30 @@
package com.subtracks.playback
import androidx.annotation.OptIn
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
/**
* Reports a track whose duration is known from its metadata as finite rather than live.
*
* Media3 marks a progressive stream live when the data source reports no length and the extractor
* no duration. The app declares a length only for a transcode of a track that has one in a
* length-estimable format, so a chunked response stays live for an Opus/Ogg/Vorbis transcode, for
* a transcode of a track whose library duration is missing, and for a direct play the server does
* not give a Content-Length. A live item makes the media session publish no playback position, so
* the notification loses its progress even though the item carries a real duration. The stream is
* still not seekable; this only restores the position the session reports.
*/
@OptIn(UnstableApi::class)
internal class KnownDurationPlayer(
player: Player,
) : ForwardingPlayer(player) {
override fun isCurrentMediaItemLive(): Boolean = if (declaredDurationMs() != null) false else super.isCurrentMediaItemLive()
private fun declaredDurationMs(): Long? =
currentMediaItem
?.mediaMetadata
?.durationMs
?.takeIf { it > 0 }
}
@@ -12,6 +12,8 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.ArtistRefsCodec
import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository
@@ -229,16 +231,51 @@ class Media3PlayerHandle(
.orEmpty(),
artist = mediaItem.mediaMetadata.artist?.toString(),
album = mediaItem.mediaMetadata.albumTitle?.toString(),
artists = mediaItem.artistRefs(),
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
durationMs =
mediaItem.mediaMetadata.extras
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
?.getLong(EXTRA_DURATION_MS),
replayGain = mediaItem.replayGain(),
)
}
internal fun MediaItem.artistRefs(): List<ArtistRef> =
mediaMetadata.extras
?.getString(EXTRA_ARTISTS)
?.let(ArtistRefsCodec::decode)
.orEmpty()
private const val EXTRA_COVER_ART_ID = "coverArtId"
private const val EXTRA_ARTISTS = "artists"
private const val EXTRA_DURATION_MS = "durationMs"
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
private const val EXTRA_ALBUM_CONTEXT = "albumContext"
internal fun MediaItem?.replayGain(): ReplayGain {
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
return ReplayGain(
trackGain = extras.gain(EXTRA_REPLAY_GAIN_TRACK_GAIN),
albumGain = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_GAIN),
trackPeak = extras.gain(EXTRA_REPLAY_GAIN_TRACK_PEAK),
albumPeak = extras.gain(EXTRA_REPLAY_GAIN_ALBUM_PEAK),
)
}
internal fun MediaItem?.albumContext(): Boolean = this?.mediaMetadata?.extras?.getBoolean(EXTRA_ALBUM_CONTEXT) ?: false
private fun Bundle.putGain(
key: String,
value: Float?,
) {
if (value != null) putFloat(key, value)
}
private fun Bundle.gain(key: String): Float? = if (containsKey(key)) getFloat(key) else null
internal fun queueMediaItem(
item: QueueItem,
@@ -259,7 +296,13 @@ internal fun queueMediaItem(
.setExtras(
Bundle().apply {
putString(EXTRA_COVER_ART_ID, item.coverArtId)
putString(EXTRA_ARTISTS, ArtistRefsCodec.encode(item.artists))
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
putBoolean(EXTRA_ALBUM_CONTEXT, item.albumContext)
},
).build()
val builder =
@@ -1,6 +1,7 @@
package com.subtracks.playback
import com.subtracks.UiMessage
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueEntry
@@ -34,6 +35,9 @@ data class QueueItem(
val album: String?,
val coverArtId: String?,
val durationMs: Long? = null,
val replayGain: ReplayGain = ReplayGain(),
val albumContext: Boolean = false,
val artists: List<ArtistRef> = emptyList(),
)
data class QueueContext(
@@ -392,7 +396,7 @@ class PlaybackController(
val current = currentPosition() ?: return@withLock
val playing = player.playWhenReady
val wasUpNext = snapshot.isUpNext(current)
val removed = if (position == current) null else queueRepository.itemAt(snapshot, position)?.toQueueItem()
val removed = if (position == current) null else queueItemAt(snapshot, position)
queueRepository.removeAt(snapshot, position)
var updated = readSnapshot()
this.snapshot = updated
@@ -544,13 +548,13 @@ class PlaybackController(
return@withLock
}
when {
fromInWindow && toInWindow -> {
fromInWindow && toInWindow && (snapshot.upNextSize == 0L || snapshot.isUpNext(from)) -> {
updating = true
player.move((from - windowStart).toInt(), (to - windowStart).toInt())
updating = false
}
!fromInWindow && !toInWindow -> {
!fromInWindow && !toInWindow && (snapshot.upNextSize == 0L || snapshot.isUpNext(from)) -> {
// The moved track never enters the window, so the playlist is unchanged; only
// the window's positions shift, and a move that spans it shifts them by one.
when {
@@ -692,12 +696,29 @@ class PlaybackController(
}
return
}
when {
current < snapshot.size - 1 -> jumpTo(current + 1)
repeatMode != RepeatMode.Off -> jumpTo(0)
}
nextPosition(snapshot, current)?.let { jumpTo(it) }
}
private fun nextPosition(
snapshot: QueueSnapshot,
current: Long,
): Long? =
when {
current < snapshot.size - 1 -> current + 1
repeatMode != RepeatMode.Off -> 0L
else -> null
}
private fun previousPosition(
snapshot: QueueSnapshot,
current: Long,
): Long? =
when {
current > 0 -> current - 1
repeatMode == RepeatMode.All -> snapshot.size - 1
else -> null
}
private suspend fun consumeUpNext(position: Long) =
startLock.withLock {
val snapshot = snapshot ?: return@withLock
@@ -783,19 +804,12 @@ class PlaybackController(
}
return
}
when {
current > 0 -> {
jumpTo(current - 1)
}
repeatMode == RepeatMode.All -> {
jumpTo(snapshot.size - 1)
}
else -> {
player.seekTo(0)
refresh()
}
val target = previousPosition(snapshot, current)
if (target != null) {
jumpTo(target)
} else {
player.seekTo(0)
refresh()
}
}
@@ -813,10 +827,7 @@ class PlaybackController(
}
return
}
when {
current > 0 -> jumpTo(current - 1)
repeatMode == RepeatMode.All -> jumpTo(snapshot.size - 1)
}
previousPosition(snapshot, current)?.let { jumpTo(it) }
}
fun addToQueue(
@@ -933,12 +944,16 @@ class PlaybackController(
}
val snapshot = current ?: return null
val at = position ?: return null
if (!isOffline) return queueRepository.itemAt(snapshot, at + 1)?.toQueueItem()
if (!isOffline) {
return nextPosition(snapshot, at)
?.takeIf { it != at }
?.let { queueItemAt(snapshot, it) }
}
downloads.awaitLoaded()
val target =
downloadedAtOrAfter(snapshot, at + 1)
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
return target?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
}
suspend fun previousItem(): QueueItem? {
@@ -952,12 +967,16 @@ class PlaybackController(
}
val snapshot = current ?: return null
val at = position ?: return null
if (!isOffline) return if (at <= 0) null else queueRepository.itemAt(snapshot, at - 1)?.toQueueItem()
if (!isOffline) {
return previousPosition(snapshot, at)
?.takeIf { it != at }
?.let { queueItemAt(snapshot, it) }
}
downloads.awaitLoaded()
val target =
downloadedAtOrBefore(snapshot, at - 1)
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
return target?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
}
private fun play(
@@ -1116,7 +1135,7 @@ class PlaybackController(
lastPosition = actual
if (actual != requested) queueRepository.setCursor(actual)
updating = true
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
player.setWindow(window.map { it.playerItem(snapshot) }, startIndex, startPositionMs)
updating = false
if (autoplay) {
player.prepare()
@@ -1156,7 +1175,7 @@ class PlaybackController(
window.forEachIndexed { index, entry ->
val local = downloads.localUri(entry.item.song.id) != null
if (local != (uris[index]?.startsWith(FILE_SCHEME) == true)) {
player.replaceItem(index, entry.item.toQueueItem())
player.replaceItem(index, entry.playerItem(snapshot))
replaced = true
}
}
@@ -1271,8 +1290,8 @@ class PlaybackController(
updating = true
try {
while (windowStart > desiredStart) {
val item = queueRepository.itemAt(snapshot, windowStart - 1) ?: break
player.addFirst(item.toQueueItem())
val item = queueItemAt(snapshot, windowStart - 1) ?: break
player.addFirst(item)
windowStart--
}
while (windowStart < desiredStart) {
@@ -1280,8 +1299,8 @@ class PlaybackController(
windowStart++
}
while (windowEnd < desiredEnd) {
val item = queueRepository.itemAt(snapshot, windowEnd + 1) ?: break
player.addLast(item.toQueueItem())
val item = queueItemAt(snapshot, windowEnd + 1) ?: break
player.addLast(item)
windowEnd++
}
while (windowEnd > desiredEnd) {
@@ -1299,6 +1318,7 @@ class PlaybackController(
position: Long,
) {
val player = player ?: return
val snapshot = this.snapshot ?: return
downloads.awaitLoaded()
val ordered =
if (offline) window.filter { downloads.localUri(it.item.song.id) != null } else window
@@ -1312,7 +1332,7 @@ class PlaybackController(
val current = ordered[index]
val wantsLocal = downloads.localUri(current.item.song.id) != null
val isLocal = player.itemUris().getOrNull(player.currentIndex)?.startsWith(FILE_SCHEME) == true
if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.item.toQueueItem())
if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.playerItem(snapshot))
windowPositions = LongArray(ordered.size) { ordered[it].position }
windowFiltered = offline
windowStart = ordered.first().position
@@ -1322,8 +1342,8 @@ class PlaybackController(
try {
repeat(player.currentIndex) { player.removeFirst() }
while (player.itemCount > 1) player.removeLast()
for (i in index - 1 downTo 0) player.addFirst(ordered[i].item.toQueueItem())
for (i in index + 1 until ordered.size) player.addLast(ordered[i].item.toQueueItem())
for (i in index - 1 downTo 0) player.addFirst(ordered[i].playerItem(snapshot))
for (i in index + 1 until ordered.size) player.addLast(ordered[i].playerItem(snapshot))
} finally {
updating = false
}
@@ -1465,6 +1485,17 @@ class PlaybackController(
return snapshot
}
private fun QueueWindowItem.playerItem(snapshot: QueueSnapshot): QueueItem =
item.toQueueItem(albumContext = snapshot.isAlbumContext(position))
private suspend fun queueItemAt(
snapshot: QueueSnapshot,
position: Long,
): QueueItem? =
queueRepository
.itemAt(snapshot, position)
?.toQueueItem(albumContext = snapshot.isAlbumContext(position))
private fun contextAt(position: Long?): QueueContext? {
val snapshot = snapshot ?: return null
val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null
@@ -34,6 +34,7 @@ import com.google.common.util.concurrent.ListenableFuture
import com.subtracks.MainActivity
import com.subtracks.R
import com.subtracks.UserAgent
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
@@ -43,6 +44,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.koin.core.context.GlobalContext
@@ -60,6 +62,9 @@ class PlaybackService : MediaSessionService() {
private var starred = false
private var starJob: Job? = null
private val gainProcessor: ReplayGainProcessor = ReplayGainProcessor { replayGain.applyItem(it) }
private val replayGain: ReplayGainController = ReplayGainController { gainProcessor.setLinearGain(it) }
private val starCallback =
object : MediaSession.Callback {
override fun onConnect(
@@ -97,10 +102,18 @@ class PlaybackService : MediaSessionService() {
val sources = GlobalContext.get().get<SourceRepository>()
sourceRepository = sources
library = GlobalContext.get().get<LibraryRepository>()
val preferences = GlobalContext.get().get<UserPreferences>()
val cache = StreamCache.open(this)
scope.launch {
preferences
.replayGainMode()
.combine(preferences.replayGainPreampDb()) { mode, preamp -> ReplayGainSettings(mode, preamp) }
.collect(replayGain::setSettings)
}
val exoPlayer =
ExoPlayer
.Builder(this)
.setRenderersFactory(ReplayGainRenderersFactory(this, gainProcessor))
.setAudioAttributes(
AudioAttributes
.Builder()
@@ -130,6 +143,7 @@ class PlaybackService : MediaSessionService() {
}
},
)
val player = KnownDurationPlayer(exoPlayer)
val loader =
CoverArtBitmapLoader(
applicationContext,
@@ -139,7 +153,7 @@ class PlaybackService : MediaSessionService() {
artworkLoader = loader
session =
MediaSession
.Builder(this, exoPlayer)
.Builder(this, player)
.setSessionActivity(
PendingIntent.getActivity(
this,
@@ -1,13 +1,17 @@
package com.subtracks.playback
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.SongItem
fun SongItem.toQueueItem() =
fun SongItem.toQueueItem(albumContext: Boolean = false) =
QueueItem(
id = song.id,
title = song.title,
artist = song.artist,
artists = song.artistRefs,
album = song.album,
coverArtId = coverArt,
durationMs = song.duration?.let { it * 1000 },
replayGain = song.replayGain(),
albumContext = albumContext,
)
@@ -0,0 +1,54 @@
package com.subtracks.playback
import com.subtracks.data.model.Song
import com.subtracks.data.prefs.ReplayGainMode
import kotlin.math.log10
import kotlin.math.min
import kotlin.math.pow
data class ReplayGain(
val trackGain: Float? = null,
val albumGain: Float? = null,
val trackPeak: Float? = null,
val albumPeak: Float? = null,
)
internal data class ReplayGainSettings(
val mode: ReplayGainMode,
val preampDb: Float,
)
internal fun Song.replayGain(): ReplayGain =
ReplayGain(
trackGain = trackGain,
albumGain = albumGain,
trackPeak = trackPeak,
albumPeak = albumPeak,
)
internal fun replayGainLinearGain(
gain: ReplayGain,
settings: ReplayGainSettings,
albumContext: Boolean = false,
): Float {
if (settings.mode == ReplayGainMode.Off) return 1f
// gonic sends a peak of 0 when it only has album gain, so a non-positive or non-finite peak means
// "no peak data" and is ignored rather than treated as silence. A boost cannot be checked for
// headroom without a peak, so a peakless track is only ever attenuated.
val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f }
val album = settings.mode == ReplayGainMode.Album || (settings.mode == ReplayGainMode.Auto && albumContext)
val (requestedDb, peak) =
if (album) {
(gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
} else {
(gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
}
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
val requested = gainDb + settings.preampDb
val maxGainDb = peak?.let(::maxSafeGainDb) ?: 0f
val applied = min(requested, maxGainDb)
return 10f.pow(applied / 20f).takeIf { it.isFinite() && it > 0f } ?: 1f
}
private fun maxSafeGainDb(peak: Float): Float = -20f * log10(peak)
@@ -0,0 +1,29 @@
package com.subtracks.playback
import androidx.media3.common.MediaItem
import com.subtracks.data.prefs.ReplayGainMode
internal class ReplayGainController(
private val apply: (Float) -> Unit,
) {
private var track = ReplayGain()
private var albumContext = false
private var settings = ReplayGainSettings(ReplayGainMode.Auto, 0f)
@Synchronized
fun applyItem(item: MediaItem) {
track = item.replayGain()
albumContext = item.albumContext()
applyGain()
}
@Synchronized
fun setSettings(value: ReplayGainSettings) {
settings = value
applyGain()
}
private fun applyGain() {
apply(replayGainLinearGain(track, settings, albumContext))
}
}
@@ -0,0 +1,71 @@
package com.subtracks.playback
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Timeline
import androidx.media3.common.audio.AudioProcessor.AudioFormat
import androidx.media3.common.audio.AudioProcessor.StreamMetadata
import androidx.media3.common.audio.BaseAudioProcessor
import androidx.media3.common.util.UnstableApi
import java.nio.ByteBuffer
import kotlin.math.roundToInt
@OptIn(UnstableApi::class)
internal class ReplayGainProcessor(
private val onItem: (MediaItem) -> Unit = {},
) : BaseAudioProcessor() {
@Volatile
private var linearGain = 1f
fun setLinearGain(value: Float) {
linearGain = value
}
override fun onConfigure(inputAudioFormat: AudioFormat): AudioFormat = inputAudioFormat
// DefaultAudioSink flushes the processors on every configuration, which happens at each media
// item transition even when the format is unchanged, and hands over the timeline and the period
// being flushed (androidx/media#418). That is early enough to set the new item's gain before any
// of its buffers are processed, unlike Player.Listener.onMediaItemTransition, which the app
// thread receives afterwards.
override fun onFlush(streamMetadata: StreamMetadata) {
streamMetadata.currentItem()?.let(onItem)
}
override fun queueInput(inputBuffer: ByteBuffer) {
val output = replaceOutputBuffer(inputBuffer.remaining())
val gain = linearGain.takeIf { it.isFinite() } ?: 1f
when (inputAudioFormat.encoding) {
C.ENCODING_PCM_16BIT -> {
while (inputBuffer.remaining() >= 2) {
output.putShort((inputBuffer.short * gain).roundToInt().coerceIn(MIN_SAMPLE, MAX_SAMPLE).toShort())
}
}
C.ENCODING_PCM_FLOAT -> {
while (inputBuffer.remaining() >= 4) output.putFloat(inputBuffer.float * gain)
}
else -> {
if (inputBuffer.hasRemaining()) output.put(inputBuffer)
}
}
inputBuffer.position(inputBuffer.limit())
output.flip()
}
private companion object {
const val MIN_SAMPLE = -32768
const val MAX_SAMPLE = 32767
}
}
@OptIn(UnstableApi::class)
internal fun StreamMetadata.currentItem(): MediaItem? {
val uid = periodUid ?: return null
val index = timeline.getIndexOfPeriod(uid)
if (index == C.INDEX_UNSET) return null
val period = timeline.getPeriod(index, Timeline.Period())
return timeline.getWindow(period.windowIndex, Timeline.Window()).mediaItem
}
@@ -0,0 +1,38 @@
package com.subtracks.playback
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import com.subtracks.log.Log
@OptIn(UnstableApi::class)
internal class ReplayGainRenderersFactory(
context: Context,
private val processor: ReplayGainProcessor,
) : DefaultRenderersFactory(context) {
// Mirrors the stock DefaultRenderersFactory.buildAudioSink. setAudioProcessors keeps the default
// chain and prepends ours; DefaultAudioSink omits the whole chain when float output is enabled,
// and AudioSink.setOffloadMode bypasses it entirely.
public override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioOutputPlaybackParameters: Boolean,
): AudioSink {
if (enableFloatOutput) {
Log.w(FLOAT_OUTPUT_TAG, "float output is on, so replay gain is not applied to high-resolution tracks")
}
return DefaultAudioSink
.Builder(context)
.setEnableFloatOutput(enableFloatOutput)
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParameters)
.setAudioProcessors(arrayOf(processor))
.build()
}
private companion object {
const val FLOAT_OUTPUT_TAG = "replaygain"
}
}
+122 -20
View File
@@ -61,14 +61,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.subtracks.R
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Song
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.AlbumInfoDialog
import com.subtracks.ui.components.ArtistChooserSheet
import com.subtracks.ui.components.ArtistInfoDialog
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.PlaylistInfoDialog
import com.subtracks.ui.components.SongInfoDialog
import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.downloads.DownloadsRoute
@@ -230,7 +236,8 @@ private fun MainNavigation() {
var settleJob by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
val contextMenuHost = remember { ContextMenuHost() }
var infoSong by remember { mutableStateOf<Song?>(null) }
var infoTarget by remember { mutableStateOf<MenuTarget?>(null) }
var artistChooser by remember { mutableStateOf<List<ArtistRef>?>(null) }
val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null)
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
@@ -312,7 +319,8 @@ private fun MainNavigation() {
nowPlayingLauncher.openNowPlaying.collect {
focusManager.clearFocus()
contextMenuHost.dismissTransients()
infoSong = null
infoTarget = null
artistChooser = null
showingQueue = false
if (navController.currentDestination?.route in DETAIL_ROUTES) {
navController.popBackStack(Routes.LIBRARY, inclusive = false)
@@ -375,7 +383,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
@@ -418,7 +434,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
@@ -514,7 +538,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId ->
@@ -538,7 +570,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId ->
@@ -562,7 +602,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId ->
@@ -588,7 +636,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(
artist.id,
artist.coverArt ?: artist.coverArts.firstOrNull(),
),
)
},
onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
@@ -725,23 +781,69 @@ private fun MainNavigation() {
target = target,
actions = contextMenuHost.actions,
onDismiss = { contextMenuHost.dismiss() },
onInfo = { infoSong = it },
onInfo = { infoTarget = it },
onChooseArtists = { artistChooser = it },
)
}
infoSong?.let { song ->
val genres by
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
.collectAsStateWithLifecycle(initialValue = emptyList())
SongInfoDialog(
song = song,
download = downloads[song.id],
localFile = downloadRepository.localFile(song.id),
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
onDismiss = { infoSong = null },
genres = genres,
artistChooser?.let { artists ->
ArtistChooserSheet(
artists = artists,
onArtistClick = { artist ->
artistChooser = null
navigateDetail(
Routes.ARTIST_DETAIL,
"artistId",
artist.id,
Routes.artist(artist.id),
)
},
onDismiss = { artistChooser = null },
)
}
when (val target = infoTarget) {
is MenuTarget.Song -> {
val song = target.song
val genres by
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
.collectAsStateWithLifecycle(initialValue = emptyList())
SongInfoDialog(
song = song,
download = downloads[song.id],
localFile = downloadRepository.localFile(song.id),
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
onDismiss = { infoTarget = null },
genres = genres,
)
}
is MenuTarget.Album -> {
AlbumInfoDialog(
album = target.album,
download = target.download,
onDismiss = { infoTarget = null },
)
}
is MenuTarget.Artist -> {
ArtistInfoDialog(
artist = target.artist,
onDismiss = { infoTarget = null },
)
}
is MenuTarget.Playlist -> {
PlaylistInfoDialog(
playlist = target.playlist,
onDismiss = { infoTarget = null },
)
}
null -> {
Unit
}
}
}
}
@@ -0,0 +1,310 @@
package com.subtracks.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.repo.SourceRepository
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
data class ArtistArt(
val image: CoverArtRef? = null,
val thumbnail: CoverArtRef? = null,
val covers: List<CoverArtRef> = emptyList(),
val fallback: ArtistArt? = null,
) {
val primary: CoverArtRef? get() = image ?: covers.firstOrNull()
}
fun SourceRepository.artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt =
resolveArtistArt(
artist = artist,
coverArt = this::coverArt,
artistImage = this::artistImage,
offline = offline.value,
forIcon = forIcon,
)
internal fun resolveArtistArt(
artist: Artist,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistImage: (String, String?) -> CoverArtRef?,
offline: Boolean,
forIcon: Boolean,
): ArtistArt {
val coverIds = if (offline) artist.downloadedCoverArts else artist.coverArts
val grid =
ArtistArt(
thumbnail = coverIds.singleOrNull()?.let { coverArt(it, true) },
covers = coverIds.take(4).mapNotNull { coverArt(it, forIcon || coverIds.size > 1) },
)
val own =
artist.coverArt?.let { coverId ->
coverArt(coverId, forIcon)?.let { image ->
ArtistArt(
image = image,
thumbnail = if (forIcon) image else coverArt(coverId, true),
fallback = grid,
)
}
}
return artist.artistImageUrl
?.let { artistImage(artist.id, it) }
?.let { ArtistArt(image = it, thumbnail = it, fallback = own ?: grid) }
?: own
?: grid
}
@Composable
fun ArtistIcon(
artist: Artist,
art: ArtistArt,
modifier: Modifier = Modifier,
) {
var shown by remember(artist.id, art) { mutableStateOf(art) }
val showFallback = {
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
}
when {
shown.image != null -> {
CoverArt(
ref = shown.image,
name = artist.name,
modifier = modifier,
onError = showFallback,
)
}
shown.covers.size >= 2 -> {
ArtistArtGrid(covers = shown.covers, name = artist.name, modifier = modifier)
}
else -> {
CoverArt(ref = shown.covers.firstOrNull(), name = artist.name, modifier = modifier)
}
}
}
@Composable
fun ArtistHeroArt(
art: ArtistArt,
name: String,
modifier: Modifier = Modifier,
) {
var shown by remember(art) { mutableStateOf(art) }
val showFallback = {
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
}
when {
shown.image != null -> {
CoverArt(
ref = shown.image,
name = name,
thumbnailRef = shown.thumbnail,
showPlaceholder = false,
modifier = modifier,
onError = showFallback,
)
}
shown.covers.isNotEmpty() -> {
ArtistArtGrid(
covers = shown.covers,
name = name,
singleThumbnail = shown.thumbnail,
modifier = modifier,
)
}
else -> {
CoverArt(ref = null, name = name, modifier = modifier)
}
}
}
@Composable
fun ArtistArtGrid(
covers: List<CoverArtRef>,
name: String,
modifier: Modifier = Modifier,
singleThumbnail: CoverArtRef? = null,
) {
when {
covers.size >= 4 -> {
Column(modifier) {
Row(Modifier.weight(1f).fillMaxWidth()) {
covers.take(2).forEach { ref ->
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
}
}
Row(Modifier.weight(1f).fillMaxWidth()) {
covers.drop(2).take(2).forEach { ref ->
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
}
}
}
}
covers.size == 3 -> {
Box(modifier) {
covers.forEachIndexed { index, ref ->
CoverArt(
ref = ref,
name = name,
modifier = Modifier.matchParentSize().clip(YRegionShape(index)),
)
}
}
}
covers.size == 2 -> {
Row(modifier) {
covers.forEach { ref ->
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
}
}
}
covers.size == 1 -> {
CoverArt(
ref = covers.first(),
name = name,
thumbnailRef = singleThumbnail,
showPlaceholder = false,
modifier = modifier,
)
}
}
}
private class YRegionShape(
private val region: Int,
) : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
val center = Offset(size.width / 2f, size.height / 2f)
val left = rayPoint(size, 150.0)
val right = rayPoint(size, 30.0)
val top = Offset(size.width / 2f, 0f)
val path = Path()
when (region) {
0 -> {
path.moveTo(center.x, center.y)
path.lineTo(right.x, right.y)
addClockwise(path, size, right, left)
}
1 -> {
path.moveTo(center.x, center.y)
path.lineTo(left.x, left.y)
addClockwise(path, size, left, top)
}
else -> {
path.moveTo(center.x, center.y)
path.lineTo(top.x, top.y)
addClockwise(path, size, top, right)
}
}
path.lineTo(center.x, center.y)
path.close()
return Outline.Generic(path)
}
}
private fun rayPoint(
size: Size,
angleDegrees: Double,
): Offset {
val cx = size.width / 2.0
val cy = size.height / 2.0
val radians = angleDegrees * PI / 180.0
val dx = cos(radians)
val dy = sin(radians)
val tx =
when {
dx > 0 -> (size.width - cx) / dx
dx < 0 -> -cx / dx
else -> Double.POSITIVE_INFINITY
}
val ty =
when {
dy > 0 -> (size.height - cy) / dy
dy < 0 -> -cy / dy
else -> Double.POSITIVE_INFINITY
}
val t = min(tx, ty)
return Offset((cx + dx * t).toFloat(), (cy + dy * t).toFloat())
}
private fun addClockwise(
path: Path,
size: Size,
from: Offset,
to: Offset,
) {
val w = size.width
val h = size.height
val corners =
listOf(
Offset(0f, 0f),
Offset(w, 0f),
Offset(w, h),
Offset(0f, h),
)
val fromParam = perimeterParam(from, size)
val toParam = perimeterParam(to, size)
if (fromParam <= toParam) {
corners
.filter { perimeterParam(it, size) > fromParam && perimeterParam(it, size) < toParam }
.forEach { path.lineTo(it.x, it.y) }
} else {
corners
.filter { perimeterParam(it, size) > fromParam }
.forEach { path.lineTo(it.x, it.y) }
corners
.filter { perimeterParam(it, size) < toParam }
.forEach { path.lineTo(it.x, it.y) }
}
path.lineTo(to.x, to.y)
}
private fun perimeterParam(
point: Offset,
size: Size,
): Float {
val w = size.width
val h = size.height
return when {
point.y <= 0f -> point.x
point.x >= w -> w + point.y
point.y >= h -> w + h + (w - point.x)
else -> 2f * w + h + (h - point.y)
}
}
@@ -0,0 +1,72 @@
package com.subtracks.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Person
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.ArtistRef
const val ARTIST_CHOOSER_TAG = "artistChooser"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ArtistChooserSheet(
artists: List<ArtistRef>,
onArtistClick: (ArtistRef) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
dragHandle = null,
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.testTag(ARTIST_CHOOSER_TAG),
) {
Text(
text = stringResource(R.string.artist_chooser_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp),
)
Column(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 400.dp)
.verticalScroll(rememberScrollState()),
) {
artists.forEach { artist ->
ListItem(
headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) },
leadingContent = { Icon(Icons.Rounded.Person, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable { onArtistClick(artist) },
)
}
}
Spacer(Modifier.height(16.dp))
}
}
@@ -85,12 +85,15 @@ fun CoverArt(
square: Boolean = true,
elevation: Dp = 0.dp,
colorFilter: ColorFilter? = null,
onError: (() -> Unit)? = null,
) {
val context = LocalPlatformContext.current
val refKey = ref?.cacheKey
val thumbnailKey = thumbnailRef?.cacheKey
val hasThumbnailFallback = thumbnailRef != null && thumbnailRef != ref
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) }
var thumbnailFailed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
val letterScheme =
if (showPlaceholder || (failed && !thumbnailLoaded)) {
@@ -115,6 +118,10 @@ fun CoverArt(
thumbnailLoaded = thumbnailLoaded,
onThumbnailLoaded = { thumbnailLoaded = true },
onThumbnailRatio = { thumbnailRatio = it },
onThumbnailError = {
thumbnailFailed = true
if (failed) onError?.invoke()
},
contentScale = ContentScale.Crop,
colorFilter = colorFilter,
placeholderContentColor = letterScheme?.onPrimaryContainer,
@@ -131,7 +138,10 @@ fun CoverArt(
.ratioOrNull()
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
},
onError = { failed = true },
onError = {
failed = true
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
},
modifier = Modifier.fillMaxSize(),
)
}
@@ -160,7 +170,10 @@ fun CoverArt(
)
}
},
onError = { failed = true },
onError = {
failed = true
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
},
contentScale = ContentScale.Fit,
filterQuality = FilterQuality.High,
)
@@ -179,6 +192,10 @@ fun CoverArt(
thumbnailLoaded = thumbnailLoaded,
onThumbnailLoaded = { thumbnailLoaded = true },
onThumbnailRatio = { thumbnailRatio = it },
onThumbnailError = {
thumbnailFailed = true
if (failed) onError?.invoke()
},
contentScale = ContentScale.Fit,
colorFilter = colorFilter,
placeholderContentColor = letterScheme?.onPrimaryContainer,
@@ -208,6 +225,7 @@ private fun BoxScope.CoverArtContent(
thumbnailLoaded: Boolean,
onThumbnailLoaded: () -> Unit,
onThumbnailRatio: (Float) -> Unit,
onThumbnailError: () -> Unit,
contentScale: ContentScale,
colorFilter: ColorFilter?,
placeholderContentColor: Color?,
@@ -246,6 +264,7 @@ private fun BoxScope.CoverArtContent(
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
}
},
onError = { onThumbnailError() },
modifier = Modifier.fillMaxSize(),
)
}
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -47,12 +48,17 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -98,12 +104,13 @@ fun HeroDetailScaffold(
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
content: LazyListScope.(rowModifier: Modifier) -> Unit,
modifier: Modifier = Modifier,
contentReady: Boolean = true,
) {
val effectiveArtwork = artwork ?: baseArtworkColors()
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(effectiveArtwork) {
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
val listState = rememberLazyListState()
val listState = rememberRestorableLazyListState(contentReady)
val scope = rememberCoroutineScope()
val fill = rememberViewportFill(listState)
val density = LocalDensity.current
@@ -114,9 +121,9 @@ fun HeroDetailScaffold(
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
var headerHeightPx by remember { mutableFloatStateOf(0f) }
var rowHeightPx by remember { mutableFloatStateOf(0f) }
var controlsTopPx by remember { mutableFloatStateOf(0f) }
var headerHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
var rowHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
var controlsTopPx by rememberSaveable { mutableFloatStateOf(0f) }
val scrollPx by remember {
derivedStateOf {
val index = listState.firstVisibleItemIndex
@@ -206,6 +213,31 @@ fun HeroDetailScaffold(
}
}
@Composable
private fun rememberRestorableLazyListState(contentReady: Boolean): LazyListState {
val state = rememberLazyListState()
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
LaunchedEffect(contentReady) {
if (contentReady && pending) {
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
state.scrollToItem(target, offset)
pending = false
}
}
LaunchedEffect(state) {
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
.collect { (currentIndex, currentOffset) ->
if (!pending) {
index = currentIndex
offset = currentOffset
}
}
}
return state
}
@Composable
private fun HeroSubtitle(
text: String,
@@ -257,6 +289,7 @@ fun HeroHeader(
thumbnailRef: CoverArtRef? = null,
comment: String? = null,
onSubtitleClick: (() -> Unit)? = null,
nameMaxLines: Int = 2,
) {
Column(
modifier =
@@ -278,7 +311,7 @@ fun HeroHeader(
text = name,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
maxLines = 2,
maxLines = nameMaxLines,
overflow = TextOverflow.Ellipsis,
)
val subtitleIsTarget = onSubtitleClick != null && subtitle.isNotEmpty() && comment.isNullOrBlank()
@@ -290,7 +323,11 @@ fun HeroHeader(
modifier =
Modifier
.heightIn(min = 32.dp)
.clickable(onClickLabel = stringResource(R.string.open_artist)) { onSubtitleClick() },
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClickLabel = stringResource(R.string.open_artist),
) { onSubtitleClick() },
contentAlignment = Alignment.TopCenter,
) {
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
@@ -6,12 +6,16 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
import androidx.compose.material.icons.rounded.Album
import androidx.compose.material.icons.rounded.Cancel
@@ -47,6 +51,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadList
@@ -77,7 +82,7 @@ sealed interface MenuTarget {
) : MenuTarget {
override val title: String get() = song.title
override val subtitle: String? get() = song.artist
override val subtitle: String? get() = song.artistSubtitle()
}
data class Album(
@@ -194,7 +199,8 @@ fun ItemContextMenu(
target: MenuTarget,
actions: ItemActions,
onDismiss: () -> Unit,
onInfo: ((SongModel) -> Unit)? = null,
onInfo: ((MenuTarget) -> Unit)? = null,
onChooseArtists: ((List<ArtistRef>) -> Unit)? = null,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
@@ -207,6 +213,11 @@ fun ItemContextMenu(
}
}
fun replaceWith(after: () -> Unit) {
onDismiss()
after()
}
val queueRef = target.queueRef()
@Composable
@@ -226,76 +237,75 @@ fun ItemContextMenu(
dragHandle = null,
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
MenuHeader(target)
when (target) {
is MenuTarget.Song -> {
queueItems()
DownloadItem(target.download, target.song, actions, ::dismiss)
StarItem(
current = target.song.starred,
onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) },
onDismiss = { dismiss() },
)
target.song.albumId?.let { albumId ->
actions.viewAlbum?.let { view ->
MenuItem(
Icons.Rounded.Album,
target.song.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_album),
) {
dismiss { view(albumId) }
Column(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = MENU_MAX_HEIGHT)
.verticalScroll(rememberScrollState()),
) {
MenuHeader(target)
when (target) {
is MenuTarget.Song -> {
queueItems()
DownloadItem(target.download, target.song, actions, ::dismiss)
StarItem(
current = target.song.starred,
onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) },
onDismiss = { dismiss() },
)
target.song.albumId?.let { albumId ->
actions.viewAlbum?.let { view ->
MenuItem(
Icons.Rounded.Album,
target.song.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_album),
) {
dismiss { view(albumId) }
}
}
}
}
target.song.artistId?.let { artistId ->
actions.viewArtist?.let { view ->
MenuItem(
Icons.Rounded.Person,
target.song.artist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artistId) }
}
ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
onInfo?.let { info ->
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
}
}
onInfo?.let { info ->
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
}
}
is MenuTarget.Album -> {
queueItems()
BulkDownloadItem(target, actions, ::dismiss)
StarItem(
current = target.album.starred,
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
onDismiss = { dismiss() },
)
target.album.artistId?.let { artistId ->
actions.viewArtist?.let { view ->
MenuItem(
Icons.Rounded.Person,
target.album.albumArtist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artistId) }
}
is MenuTarget.Album -> {
queueItems()
BulkDownloadItem(target, actions, ::dismiss)
StarItem(
current = target.album.starred,
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
onDismiss = { dismiss() },
)
ArtistItems(target.album.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
onInfo?.let { info ->
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
}
}
is MenuTarget.Artist -> {
BulkDownloadItem(target, actions, ::dismiss)
StarItem(
current = target.artist.starred,
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
onDismiss = { dismiss() },
)
onInfo?.let { info ->
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
}
}
is MenuTarget.Playlist -> {
queueItems()
BulkDownloadItem(target, actions, ::dismiss)
onInfo?.let { info ->
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
}
}
}
is MenuTarget.Artist -> {
BulkDownloadItem(target, actions, ::dismiss)
StarItem(
current = target.artist.starred,
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
onDismiss = { dismiss() },
)
}
is MenuTarget.Playlist -> {
queueItems()
BulkDownloadItem(target, actions, ::dismiss)
}
Spacer(Modifier.height(16.dp))
}
Spacer(Modifier.height(16.dp))
}
}
@@ -331,6 +341,55 @@ private fun MenuHeader(target: MenuTarget) {
}
}
@Composable
private fun ArtistItems(
artists: List<ArtistRef>,
actions: ItemActions,
onChooseArtists: ((List<ArtistRef>) -> Unit)?,
dismiss: (() -> Unit) -> Unit,
replaceWith: (() -> Unit) -> Unit,
) {
val view = actions.viewArtist ?: return
val distinct = artists.distinctBy { it.id }
when (distinct.size) {
0 -> {
Unit
}
1 -> {
val artist = distinct.first()
MenuItem(
Icons.Rounded.Person,
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artist.id) }
}
}
else -> {
if (onChooseArtists == null) {
distinct.forEach { artist ->
MenuItem(
Icons.Rounded.Person,
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artist.id) }
}
}
} else {
val fallback = stringResource(R.string.resources_filter_artist)
MenuItem(
Icons.Rounded.Person,
distinct.joinToString(" \u00B7 ") { it.name.ifBlank { fallback } },
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
) {
replaceWith { onChooseArtists(distinct) }
}
}
}
}
}
@Composable
private fun DownloadItem(
download: SongDownload?,
@@ -428,11 +487,19 @@ private fun MenuItem(
icon: ImageVector,
label: String,
tint: Color? = null,
trailing: (@Composable () -> Unit)? = null,
onClick: () -> Unit,
) {
ListItem(
headlineContent = { Text(label) },
headlineContent = {
Text(
text = label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
trailingContent = trailing,
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(onClick = onClick),
)
@@ -445,3 +512,4 @@ private fun starLabel(starred: Long?): String =
if (starred == null) stringResource(R.string.actions_star) else stringResource(R.string.actions_unstar)
private const val STAR_DISMISS_DELAY_MS = 200L
private val MENU_MAX_HEIGHT = 560.dp
@@ -0,0 +1,160 @@
package com.subtracks.ui.components
import android.content.Context
import android.text.format.DateUtils
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist
private const val ARTIST_SEPARATOR = " \u00B7 "
@Composable
fun AlbumInfoDialog(
album: Album,
download: ListDownloadStatus?,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.info)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
InfoRow(stringResource(R.string.resources_sort_by_title), album.name)
InfoRow(stringResource(R.string.info_sort_title), album.sortName.takeIfDifferentFrom(album.name))
InfoRow(stringResource(R.string.info_album_artists), album.artistRefs.names())
InfoRow(stringResource(R.string.info_album_artist_sort), album.albumArtistSort.takeIfDifferentFrom(album.albumArtist))
InfoRow(stringResource(R.string.resources_sort_by_year), album.year?.takeIf { it > 0 }?.toString())
InfoRow(stringResource(R.string.resources_filter_genre), album.genre)
InfoRow(stringResource(R.string.info_songs), album.songCount.takeIf { it > 0 }?.toString())
download?.takeIf { it.total > 0 }?.let { status ->
InfoRow(
stringResource(R.string.status_downloaded),
stringResource(R.string.info_downloaded_count, status.downloaded, status.total),
)
}
InfoRow(stringResource(R.string.info_added), album.created.takeIf { it > 0 }?.let { formatDate(context, it) })
InfoRow(stringResource(R.string.info_last_played), album.played?.let { formatDate(context, it) })
InfoRow(stringResource(R.string.info_plays), album.playCount.takeIf { it > 0 }?.toString())
InfoRow(stringResource(R.string.info_starred), if (album.starred != null) stringResource(R.string.info_yes) else null)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
)
}
@Composable
fun ArtistInfoDialog(
artist: Artist,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.info)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
InfoRow(stringResource(R.string.resources_filter_artist), artist.name)
InfoRow(stringResource(R.string.info_sort_name), artist.sortName.takeIfDifferentFrom(artist.name))
InfoRow(stringResource(R.string.info_albums), artist.albumCount.takeIf { it > 0 }?.toString())
InfoRow(stringResource(R.string.info_songs), artist.songCount.takeIf { it > 0 }?.toString())
InfoRow(stringResource(R.string.info_plays), artist.playCount.takeIf { it > 0 }?.toString())
InfoRow(stringResource(R.string.info_last_played), artist.played?.let { formatDate(context, it) })
InfoRow(stringResource(R.string.info_starred), if (artist.starred != null) stringResource(R.string.info_yes) else null)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
)
}
@Composable
fun PlaylistInfoDialog(
playlist: Playlist,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.info)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
InfoRow(stringResource(R.string.resources_sort_by_title), playlist.name)
InfoRow(stringResource(R.string.info_owner), playlist.owner)
InfoRow(stringResource(R.string.info_shared), if (playlist.shared) stringResource(R.string.info_yes) else null)
InfoRow(stringResource(R.string.info_songs), playlist.songCount.takeIf { it > 0 }?.toString())
InfoRow(stringResource(R.string.info_length), playlist.duration.takeIf { it > 0 }?.let(DateUtils::formatElapsedTime))
InfoRow(stringResource(R.string.info_created), playlist.created.takeIf { it > 0 }?.let { formatDate(context, it) })
InfoRow(stringResource(R.string.info_updated), playlist.changed.takeIf { it > 0 }?.let { formatDate(context, it) })
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
)
}
internal fun String?.takeIfDifferentFrom(regular: String?): String? =
this?.trim()?.takeIf { value -> value.isNotEmpty() && !value.equals(regular?.trim(), ignoreCase = true) }
@Composable
internal fun InfoRow(
label: String,
value: String?,
) {
if (value.isNullOrBlank()) return
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(INFO_LABEL_WIDTH),
)
Spacer(Modifier.width(INFO_LABEL_GAP))
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
}
}
private fun List<ArtistRef>.names(): String? =
map { it.name }
.filter { it.isNotBlank() }
.distinct()
.joinToString(ARTIST_SEPARATOR)
.takeIf { it.isNotEmpty() }
private fun formatDate(
context: Context,
epochSeconds: Long,
): String =
DateUtils.formatDateTime(
context,
epochSeconds * 1000,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR,
)
private val INFO_LABEL_WIDTH = 100.dp
private val INFO_LABEL_GAP = 12.dp
@@ -20,6 +20,10 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -28,6 +32,8 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.subtracks.R
@@ -43,6 +49,12 @@ fun SearchField(
) {
val hint = stringResource(R.string.search_input_placeholder)
val closeLabel = stringResource(R.string.close_search)
var textFieldValue by remember { mutableStateOf(TextFieldValue(value, selection = TextRange(value.length))) }
LaunchedEffect(value) {
if (value != textFieldValue.text) {
textFieldValue = TextFieldValue(value, selection = TextRange(value.length))
}
}
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Surface(
shape = RoundedCornerShape(8.dp),
@@ -62,8 +74,11 @@ fun SearchField(
)
Spacer(Modifier.width(8.dp))
BasicTextField(
value = value,
onValueChange = onValueChange,
value = textFieldValue,
onValueChange = {
textFieldValue = it
onValueChange(it.text)
},
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
@@ -73,7 +88,7 @@ fun SearchField(
.focusRequester(focusRequester)
.semantics { contentDescription = hint },
decorationBox = { inner ->
if (value.isEmpty()) {
if (textFieldValue.text.isEmpty()) {
Text(
text = hint,
style = MaterialTheme.typography.bodyLarge,
@@ -0,0 +1,31 @@
package com.subtracks.ui.components
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Song
private const val ARTIST_SEPARATOR = " \u00B7 "
internal fun Song.artistSubtitle(): String? = artistSubtitle(artist, artists)
internal fun Song.artistNames(): List<String> = artistNames(artist, artists)
internal fun artistNames(
artist: String?,
artists: List<ArtistRef>,
): List<String> {
val names = artists.map { it.name }.filter { it.isNotBlank() }.distinct()
return names.ifEmpty { listOfNotNull(artist?.takeIf { it.isNotBlank() }) }
}
internal fun artistSubtitle(
artist: String?,
artists: List<ArtistRef>,
): String? {
val names = artistNames(artist, artists)
if (names.isEmpty()) return null
val primary = artist?.takeIf { it in names }
return buildList {
primary?.let(::add)
addAll(names.filterNot { it == primary })
}.joinToString(ARTIST_SEPARATOR)
}
@@ -4,12 +4,7 @@ import android.text.format.DateUtils
import android.text.format.Formatter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -18,8 +13,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -53,8 +46,20 @@ fun SongInfoDialog(
title = { Text(stringResource(R.string.info)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
val multipleArtists = song.artistNames().size > 1
val regularArtistSort = song.artist?.takeIf { it.isNotBlank() } ?: song.artistRefs.firstOrNull()?.name
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
InfoRow(stringResource(R.string.resources_filter_artist), song.artist)
InfoRow(stringResource(R.string.info_sort_title), song.sortName.takeIfDifferentFrom(song.title))
InfoRow(
stringResource(
if (multipleArtists) R.string.info_artists else R.string.resources_filter_artist,
),
song.artistSubtitle(),
)
InfoRow(
stringResource(if (multipleArtists) R.string.info_artists_sort else R.string.info_artist_sort),
song.artistSort.takeIfDifferentFrom(regularArtistSort),
)
InfoRow(stringResource(R.string.resources_filter_album), song.album)
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
InfoRow(
@@ -92,32 +97,6 @@ fun SongInfoDialog(
)
}
@Composable
private fun InfoRow(
label: String,
value: String?,
) {
if (value.isNullOrBlank()) return
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(INFO_LABEL_WIDTH),
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
}
}
private val INFO_LABEL_WIDTH = 80.dp
@Composable
private fun downloadStatus(status: DownloadStatus): String =
when (status) {
@@ -13,10 +13,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -274,7 +274,7 @@ private fun NodeRow(
leadingContent = {
IconButton(onClick = onToggle) {
Icon(
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
imageVector = if (expanded) Icons.Rounded.KeyboardArrowDown else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = if (expanded) stringResource(R.string.collapse) else stringResource(R.string.expand),
)
}
@@ -31,6 +31,7 @@ data class DownloadArtistNode(
val name: String,
val bytes: Long,
val albums: List<DownloadAlbumNode>,
val sortName: String = name,
)
data class DownloadAlbumNode(
@@ -38,6 +39,7 @@ data class DownloadAlbumNode(
val name: String,
val bytes: Long,
val songs: List<DownloadedSong>,
val sortName: String = name,
)
@OptIn(ExperimentalCoroutinesApi::class)
@@ -105,6 +107,7 @@ internal fun buildTree(
id = artistKey.first,
name = artistKey.second.ifBlank { unknownArtist },
bytes = artistSongs.sumOf { it.size },
sortName = artistSongs.first().artistSort.ifBlank { artistKey.second },
albums =
artistSongs
.groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() }
@@ -113,9 +116,10 @@ internal fun buildTree(
id = albumKey.first,
name = albumKey.second.ifBlank { unknownAlbum },
bytes = albumSongs.sumOf { it.size },
songs = albumSongs.sortedBy { it.title.lowercase() },
sortName = albumSongs.first().albumSort.ifBlank { albumKey.second },
songs = albumSongs,
)
}.sortedBy { it.name.lowercase() },
}.sortedBy { it.sortName.lowercase() },
)
}.sortedBy { it.name.lowercase() },
}.sortedBy { it.sortName.lowercase() },
)
@@ -57,6 +57,8 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistIcon
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions
@@ -108,6 +110,7 @@ fun HomeListRoute(
genres = genres,
decades = decades,
coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id,
onBack = onBack,
onAlbumClick = onAlbumClick,
@@ -133,6 +136,7 @@ fun HomeListScreen(
genres: List<String> = emptyList(),
decades: List<Long> = emptyList(),
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
playingSongId: String? = null,
onBack: () -> Unit = {},
onAlbumClick: (Album) -> Unit = {},
@@ -191,7 +195,7 @@ fun HomeListScreen(
OfflineListKind.Artists -> {
ArtistsList(
artists = artists,
coverArt = coverArt,
artistArt = artistArt,
info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick,
onLongClick = onLongClick,
@@ -245,7 +249,7 @@ fun HomeListScreen(
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
ArtistsList(
artists = artists,
coverArt = coverArt,
artistArt = artistArt,
info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick,
onLongClick = onLongClick,
@@ -443,7 +447,7 @@ internal fun AlbumListRow(
@Composable
private fun ArtistsList(
artists: LazyPagingItems<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistArt: (Artist) -> ArtistArt,
info: (Artist) -> String?,
onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
@@ -460,12 +464,13 @@ private fun ArtistsList(
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
val artist = artists[index]
if (artist != null) {
val art = artistArt(artist)
ArtistListRow(
artist = artist,
coverArt = coverArt,
art = art,
info = info(artist),
onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
)
}
}
@@ -475,7 +480,7 @@ private fun ArtistsList(
@Composable
internal fun ArtistListRow(
artist: Artist,
coverArt: (String?, Boolean) -> CoverArtRef?,
art: ArtistArt,
info: String?,
onClick: () -> Unit,
onLongClick: () -> Unit,
@@ -488,9 +493,9 @@ internal fun ArtistListRow(
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CoverArt(
ref = coverArt(artist.coverArt, true),
name = artist.name,
ArtistIcon(
artist = artist,
art = art,
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
)
Spacer(Modifier.width(12.dp))
@@ -16,6 +16,8 @@ import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@@ -156,6 +158,11 @@ class HomeListViewModel(
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun play(song: Song) {
val genre = request.genre
when {
@@ -72,6 +72,8 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistIcon
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
@@ -137,6 +139,7 @@ fun HomeRoute(
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id,
topInset = topInset,
bottomInset = bottomInset,
@@ -152,6 +155,7 @@ fun HomeRoute(
HomeScreen(
feed = feed,
coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id,
topInset = topInset,
bottomInset = bottomInset,
@@ -173,6 +177,7 @@ fun HomeScreen(
coverArt: (String?, Boolean) -> CoverArtRef?,
playingSongId: String?,
modifier: Modifier = Modifier,
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
topInset: Dp = 0.dp,
bottomInset: Dp = 0.dp,
onAlbumClick: (Album) -> Unit = {},
@@ -215,6 +220,7 @@ fun HomeScreen(
HomeSection.RecentlyPlayedArtists,
feed.recentlyPlayedArtists,
coverArt,
artistArt,
onArtistClick,
onLongClick,
onMore,
@@ -249,6 +255,7 @@ fun HomeScreen(
HomeSection.MostPlayedArtists,
feed.mostPlayedArtists,
coverArt,
artistArt,
onArtistClick,
onLongClick,
onMore,
@@ -296,6 +303,7 @@ fun OfflineHomeScreen(
coverArt: (String?, Boolean) -> CoverArtRef?,
playingSongId: String?,
modifier: Modifier = Modifier,
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
topInset: Dp = 0.dp,
bottomInset: Dp = 0.dp,
onAlbumClick: (Album) -> Unit = {},
@@ -312,7 +320,7 @@ fun OfflineHomeScreen(
) {
homeTitle()
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
downloadedArtistRow(artists, coverArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
downloadedArtistRow(artists, artistArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
songList(
key = "downloaded-songs",
@@ -364,7 +372,7 @@ private fun LazyListScope.downloadedAlbumRow(
private fun LazyListScope.downloadedArtistRow(
artists: LazyPagingItems<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistArt: (Artist) -> ArtistArt,
onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
onMore: () -> Unit,
@@ -382,12 +390,12 @@ private fun LazyListScope.downloadedArtistRow(
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
val artist = artists[index]
if (artist != null) {
val ref = coverArt(artist.coverArt, true)
val art = artistArt(artist)
ArtistTile(
artist = artist,
ref = ref,
art = art,
onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
)
}
}
@@ -607,6 +615,7 @@ private fun LazyListScope.artistRow(
section: HomeSection,
artists: List<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistArt: (Artist) -> ArtistArt,
onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
onMore: (HomeSection) -> Unit,
@@ -625,12 +634,12 @@ private fun LazyListScope.artistRow(
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
) {
items(artists, key = { it.id }) { artist ->
val ref = coverArt(artist.coverArt, true)
val art = artistArt(artist)
ArtistTile(
artist = artist,
ref = ref,
art = art,
onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
)
}
}
@@ -779,7 +788,7 @@ private fun AlbumTile(
@Composable
private fun ArtistTile(
artist: Artist,
ref: CoverArtRef?,
art: ArtistArt,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
@@ -787,9 +796,9 @@ private fun ArtistTile(
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CoverArt(
ref = ref,
name = artist.name,
ArtistIcon(
artist = artist,
art = art,
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
)
Spacer(Modifier.height(6.dp))
@@ -17,6 +17,8 @@ import com.subtracks.data.repo.rediscoverCutoff
import com.subtracks.data.sync.SyncManager
import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@@ -127,6 +129,11 @@ class HomeViewModel(
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun playStarred(song: Song) {
viewModelScope.playStarredList(libraryRepository, playbackController, song)
}
@@ -213,7 +220,7 @@ internal fun CoroutineScope.playGenreList(
song: Song,
) {
launch {
val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.title, song.id)
val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.sortName, song.id)
playbackController.playGenre(song.sourceId, genre, ordinal)
}
}
@@ -36,6 +36,7 @@ import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistChooserSheet
import com.subtracks.ui.components.CloseWhenDownloadsGone
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog
@@ -66,16 +67,20 @@ fun AlbumDetailRoute(
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
playbackController: PlaybackController = koinInject(),
) {
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
val album by viewModel.album.collectAsStateWithLifecycle()
val songs by viewModel.songs.collectAsStateWithLifecycle()
val discs by viewModel.discs.collectAsStateWithLifecycle()
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<Long?>(null) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
var showArtistChooser by remember { mutableStateOf(false) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) {
pendingDelete = null
showArtistChooser = false
}
val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
@@ -126,9 +131,13 @@ fun AlbumDetailRoute(
},
onArtistClick =
album
?.takeIf { !it.albumArtist.isNullOrBlank() }
?.let { it.artistId }
?.let { id -> { onViewArtist(id) } },
?.takeIf { it.artistRefs.isNotEmpty() }
?.let {
{
val artists = it.artistRefs
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
}
},
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
)
@@ -140,6 +149,17 @@ fun AlbumDetailRoute(
onDismiss = { pendingDelete = null },
)
}
if (showArtistChooser) {
ArtistChooserSheet(
artists = album?.artistRefs.orEmpty(),
onArtistClick = { artist ->
showArtistChooser = false
onViewArtist(artist.id)
},
onDismiss = { showArtistChooser = false },
)
}
}
@Composable
@@ -195,6 +215,7 @@ fun AlbumDetailScreen(
artwork = artwork,
title = album?.name.orEmpty(),
onBack = onBack,
contentReady = songs.isNotEmpty(),
header = { controlsModifier, topInset ->
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
HeroHeader(
@@ -215,6 +236,7 @@ fun AlbumDetailScreen(
onMore = onMore,
topInset = topInset,
controlsModifier = controlsModifier,
nameMaxLines = Int.MAX_VALUE,
)
},
content = { rowModifier ->
@@ -15,7 +15,6 @@ import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@@ -35,13 +34,20 @@ class AlbumDetailViewModel(
) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
val album: Flow<Album?> = sourceId.flatMapLatest { libraryRepository.album(it, albumId) }
val album: StateFlow<Album?> =
sourceId
.flatMapLatest { libraryRepository.album(it, albumId) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val songs: Flow<List<Song>> =
val songs: StateFlow<List<Song>> =
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
val discs: StateFlow<List<Disc>> =
sourceId
.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
@@ -1,5 +1,6 @@
package com.subtracks.ui.library
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -15,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -43,6 +45,8 @@ import com.subtracks.ui.theme.PrefetchArtworkSeeds
const val ALBUM_COVER_TAG = "album-cover"
const val ALBUM_PLACEHOLDER_TAG = "album-placeholder"
@Composable
fun AlbumsContent(
items: LazyPagingItems<Album>,
@@ -117,6 +121,16 @@ fun AlbumsContent(
scrim = true,
)
}
} else {
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(2.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.testTag(ALBUM_PLACEHOLDER_TAG),
)
}
}
item(span = { GridItemSpan(maxLineSpan) }) {
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -27,12 +28,15 @@ import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Album
import androidx.compose.material.icons.rounded.Mic
import androidx.compose.material.icons.rounded.MoreHoriz
import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material.icons.rounded.Star
@@ -52,40 +56,58 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistHeroArt
import com.subtracks.ui.components.CloseWhenDownloadsGone
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
@@ -107,10 +129,16 @@ import com.subtracks.ui.theme.heroBarColor
import com.subtracks.ui.theme.rememberArtworkColors
import com.subtracks.ui.theme.rememberOverlaidNameBusy
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import kotlin.math.PI
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
private val ART_HEIGHT = 420.dp
private val TITLE_INSET = 16.dp
@@ -121,6 +149,12 @@ private val SHUFFLE_ICON = 30.dp
private val SHUFFLE_END = 22.dp
private val SHUFFLE_RESERVE = 86.dp
private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2
private val BIO_ACTION_RESERVE = 104.dp
private val BIO_ACTION_HEIGHT = 48.dp
private val BIO_ACTION_OFFSET = 16.dp
private val BIO_ACTION_END_OFFSET = 8.dp
private val BIO_TOP_GAP = 8.dp
private val BIO_BOTTOM_GAP = 8.dp
internal fun estimateScrollPx(
index: Int,
@@ -136,9 +170,18 @@ internal fun estimateScrollPx(
}
private val FADE_LEAD = 24.dp
private const val ARTIST_NAME_MAX_LINES = 6
private const val THEME_TRANSITION_MS = 100
private const val SCRIM_FADE_MS = 180
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
internal const val ARTIST_BIO_MORE_TAG = "artistBioMore"
internal const val ARTIST_BIO_LESS_TAG = "artistBioLess"
private data class BioSnapshot(
val known: Boolean,
val present: Boolean,
val biography: String?,
)
@Composable
fun ArtistDetailRoute(
@@ -152,11 +195,24 @@ fun ArtistDetailRoute(
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
playbackController: PlaybackController = koinInject(),
) {
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null)
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList())
val artist by viewModel.artist.collectAsStateWithLifecycle()
val artistName by viewModel.artistName.collectAsStateWithLifecycle()
val albums by viewModel.albums.collectAsStateWithLifecycle()
val bioSnapshots =
remember(viewModel) {
viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) }
}
val bioSnapshot by
bioSnapshots.collectAsStateWithLifecycle(
initialValue = BioSnapshot(known = false, present = false, biography = null),
)
val bioLoading by viewModel.bioLoading.collectAsStateWithLifecycle()
val bioUnavailable by viewModel.bioUnavailable.collectAsStateWithLifecycle()
val bioExpanded by viewModel.bioExpanded.collectAsStateWithLifecycle()
LaunchedEffect(artistId) { viewModel.refreshBio() }
val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val heroArt by viewModel.heroArt.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
@@ -187,10 +243,16 @@ fun ArtistDetailRoute(
ArtistDetailScreen(
artist = artist,
artistName = artistName.orEmpty(),
isAlbumArtist = albums.any { it.artistId == artistId },
albums = albums,
albums = albums ?: ArtistAlbums(),
albumsLoaded = albums != null,
bio = bioSnapshot.biography,
bioLoading = bioLoading && !bioSnapshot.present,
bioUnavailable = bioUnavailable && !bioSnapshot.present,
bioPending = !bioSnapshot.known,
bioExpanded = bioExpanded,
art = art,
artThumbnail = artThumbnail,
heroArt = heroArt,
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
coverArt = viewModel::coverArt,
onBack = onBack,
@@ -200,6 +262,8 @@ fun ArtistDetailRoute(
onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } },
starred = artist?.starred != null,
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } },
onExpandBio = viewModel::expandBio,
onCollapseBio = viewModel::collapseBio,
onShuffle = viewModel::shuffle,
)
@@ -219,10 +283,16 @@ fun ArtistDetailScreen(
artist: Artist?,
modifier: Modifier = Modifier,
artistName: String = "",
isAlbumArtist: Boolean = false,
albums: List<Album>,
albums: ArtistAlbums = ArtistAlbums(),
albumsLoaded: Boolean = true,
bio: String? = null,
bioLoading: Boolean = false,
bioUnavailable: Boolean = false,
bioPending: Boolean = false,
bioExpanded: Boolean = false,
art: CoverArtRef?,
artThumbnail: CoverArtRef? = null,
heroArt: ArtistArt? = null,
artwork: ArtworkColors? = null,
coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit,
@@ -232,9 +302,11 @@ fun ArtistDetailScreen(
onMore: () -> Unit = {},
starred: Boolean = false,
onToggleStar: (() -> Unit)? = null,
onExpandBio: () -> Unit = {},
onCollapseBio: () -> Unit = {},
onShuffle: () -> Unit = {},
) {
val listState = rememberLazyGridState()
val listState = rememberRestorableLazyGridState(albumsLoaded)
val fill = rememberViewportFill(listState)
val density = LocalDensity.current
val lineHeight =
@@ -245,10 +317,31 @@ fun ArtistDetailScreen(
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val navBarBottom = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
val albumCount = albums.size.toLong()
val albumCount = albums.count.toLong()
val displayName = artistName.ifBlank { artist?.name.orEmpty() }
val artistType = stringResource(if (isAlbumArtist) R.string.artist_type_album_artist else R.string.artist_type_artist)
val imageTitleTop = ART_HEIGHT - TITLE_INSET - lineHeight
val artistType =
stringResource(
if (albums.albumCredits.isNotEmpty()) R.string.artist_type_album_artist else R.string.artist_type_artist,
)
val albumCountText = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
val metaText = if (albumsLoaded) stringResource(R.string.artist_meta, artistType, albumCountText) else ""
val hasBio = !bio.isNullOrBlank()
val bioCollapsed = hasBio && !bioExpanded
val bioText =
when {
bioPending -> ""
hasBio -> bio.orEmpty()
bioLoading -> stringResource(R.string.artist_bio_loading)
bioUnavailable -> stringResource(R.string.artist_bio_unavailable)
else -> stringResource(R.string.artist_bio_empty)
}
var titleHeightPx by remember { mutableFloatStateOf(0f) }
val titleHeight = if (titleHeightPx > 0f) with(density) { titleHeightPx.toDp() } else lineHeight
val titleMaxLines =
((ART_HEIGHT - TITLE_INSET - barHeight - FADE_LEAD) / lineHeight)
.toInt()
.coerceIn(1, ARTIST_NAME_MAX_LINES)
val imageTitleTop = ART_HEIGHT - TITLE_INSET - titleHeight
val fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() }
val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() }
val barFraction by remember(fadeStartPx, fadeEndPx) {
@@ -260,8 +353,8 @@ fun ArtistDetailScreen(
}
}
}
var imageHeightPx by remember { mutableFloatStateOf(0f) }
var albumHeightPx by remember { mutableFloatStateOf(0f) }
var imageHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
var albumHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
val albumRowGapPx = with(density) { ROW_GAP.toPx() }
val scrollPx by remember {
derivedStateOf {
@@ -293,7 +386,9 @@ fun ArtistDetailScreen(
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
val screenHeightPx = with(density) { maxHeight.toPx() }
val barHeightPx = with(density) { barHeight.toPx() }
val gradientScrollPx = scrollPx - imageHeightPx
// The gradient is anchored to the hero image, not the whole header, whose bio can expand.
val heroHeightPx = with(density) { ART_HEIGHT.toPx() }
val gradientScrollPx = scrollPx - heroHeightPx
val barColor = heroBarColor(effectiveArtwork, gradientScrollPx, barHeightPx, screenHeightPx)
HeroGradient(
colors = effectiveArtwork,
@@ -315,11 +410,9 @@ fun ArtistDetailScreen(
.onSizeChanged { imageHeightPx = it.height.toFloat() },
) {
Box(Modifier.fillMaxWidth()) {
CoverArt(
ref = art,
ArtistHeroArt(
art = heroArt ?: ArtistArt(image = art, thumbnail = artThumbnail),
name = displayName,
thumbnailRef = artThumbnail,
showPlaceholder = art == null,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
)
if (nameScrimAlpha > 0f) {
@@ -328,7 +421,7 @@ fun ArtistDetailScreen(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.height(ART_HEIGHT * 0.4f)
.height(maxOf(ART_HEIGHT * 0.4f, titleHeight + TITLE_INSET * 2))
.graphicsLayer { alpha = nameScrimAlpha }
.background(
Brush.verticalGradient(
@@ -343,7 +436,13 @@ fun ArtistDetailScreen(
style = imageNameStyle,
color = Color.White,
endReserve = SHUFFLE_RESERVE,
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(TITLE_INSET),
maxLines = titleMaxLines,
modifier =
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(TITLE_INSET)
.onSizeChanged { titleHeightPx = it.height.toFloat() },
)
FilledIconButton(
onClick = onShuffle,
@@ -365,95 +464,71 @@ fun ArtistDetailScreen(
}
}
Column(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = TITLE_INSET),
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = TITLE_INSET),
) {
Text(
text = artistType,
style = MaterialTheme.typography.labelLarge,
text = metaText,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(end = SHUFFLE_RESERVE),
)
ArtistBio(
text = bioText,
color =
if (hasBio) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.outline
},
collapsed = bioCollapsed,
expanded = hasBio && bioExpanded,
artist = artist,
starred = starred,
onToggleStar = onToggleStar,
onMore = onMore,
onExpand = onExpandBio,
onCollapse = onCollapseBio,
modifier =
Modifier.padding(
bottom = if (hasBio) BIO_BOTTOM_GAP else 0.dp,
),
)
// Hold the row at the button height even without the buttons, so the
// label-to-count gap is the same for every artist type.
Row(
modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (artist != null) {
IconButton(
onClick = { onToggleStar?.invoke() },
enabled = onToggleStar != null,
) {
Icon(
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription =
stringResource(
if (starred) R.string.actions_unstar else R.string.actions_star,
),
tint =
if (starred) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
IconButton(onClick = onMore) {
Icon(Icons.Rounded.MoreHoriz, contentDescription = stringResource(R.string.more_options))
}
}
}
}
}
}
itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
val art = coverArt(album.coverArt, false)
val thumbnail = coverArt(album.coverArt, true)
PrefetchArtworkSeeds(thumbnail)
Column(
modifier =
Modifier
.padding(
start = if (index % 2 == 0) 16.dp else 0.dp,
end = if (index % 2 == 1) 16.dp else 0.dp,
).combinedClickable(
onClick = { onAlbumClick(album) },
onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id])) },
).onSizeChanged { albumHeightPx = it.height.toFloat() },
) {
Box(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
) {
CoverArt(
ref = art,
name = album.name,
thumbnailRef = thumbnail,
showPlaceholder = art == null,
modifier = Modifier.fillMaxSize(),
)
ListDownloadIndicator(
status = albumDownloads[album.id],
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
scrim = true,
)
}
Text(
text = album.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
if (albums.albumCredits.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
ArtistSectionHeader(stringResource(R.string.artist_section_albums), Icons.Rounded.Album)
}
itemsIndexed(albums.albumCredits, key = { _, album -> album.id }) { index, album ->
ArtistAlbumCard(
album = album,
index = index,
coverArt = coverArt,
downloadStatus = albumDownloads[album.id],
onClick = { onAlbumClick(album) },
onLongClick = { thumbnail ->
onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id]))
},
onSizeChanged = { albumHeightPx = it },
)
Text(
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
}
}
if (albums.appearances.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
ArtistSectionHeader(stringResource(R.string.artist_section_appearances), Icons.Rounded.Mic, topGap = 16.dp)
}
itemsIndexed(albums.appearances, key = { _, album -> album.id }) { index, album ->
ArtistAlbumCard(
album = album,
index = index,
coverArt = coverArt,
downloadStatus = albumDownloads[album.id],
onClick = { onAlbumClick(album) },
onLongClick = { thumbnail ->
onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id]))
},
onSizeChanged = { albumHeightPx = it },
)
}
}
@@ -499,12 +574,441 @@ fun ArtistDetailScreen(
}
}
@Composable
private fun rememberRestorableLazyGridState(contentReady: Boolean): LazyGridState {
val state = rememberLazyGridState()
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
LaunchedEffect(contentReady) {
if (contentReady && pending) {
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
state.scrollToItem(target, offset)
pending = false
}
}
LaunchedEffect(state) {
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
.collect { (currentIndex, currentOffset) ->
if (!pending) {
index = currentIndex
offset = currentOffset
}
}
}
return state
}
// Only the lines that overlap the action buttons are narrowed; later lines use the full width.
@Composable
private fun ArtistBio(
text: String,
color: Color,
collapsed: Boolean,
expanded: Boolean,
artist: Artist?,
starred: Boolean,
onToggleStar: (() -> Unit)?,
onMore: () -> Unit,
onExpand: () -> Unit,
onCollapse: () -> Unit,
modifier: Modifier = Modifier,
) {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val style = MaterialTheme.typography.bodyMedium
val moreText = stringResource(R.string.artist_bio_more)
val lessText = stringResource(R.string.artist_bio_less)
BoxWithConstraints(modifier.fillMaxWidth().padding(top = BIO_TOP_GAP)) {
val fullWidth = with(density) { maxWidth.roundToPx() }
val reserve = with(density) { BIO_ACTION_RESERVE.roundToPx() }
val lineHeightPx =
remember(style) {
measurer.measure(AnnotatedString("X"), style, maxLines = 1).size.height
}.coerceAtLeast(1)
val reserveLines =
ceil(with(density) { (BIO_ACTION_HEIGHT - BIO_ACTION_OFFSET).toPx() } / lineHeightPx)
.toInt()
.coerceAtLeast(1)
val bioLines =
remember(
text,
fullWidth,
reserve,
reserveLines,
collapsed,
expanded,
moreText,
lessText,
) {
when {
collapsed -> {
splitBioCollapsed(
measurer,
text,
style,
fullWidth,
reserve,
reserveLines,
moreText,
maxLines = 3,
)
}
expanded -> {
splitBioExpanded(measurer, text, style, fullWidth, reserve, reserveLines, lessText)
}
else -> {
BioLines(
splitBio(measurer, text, style, fullWidth, reserve, reserveLines).map {
AnnotatedString(it.ifEmpty { " " })
},
more = false,
)
}
}
}
Box {
Column(Modifier.fillMaxWidth()) {
bioLines.lines.forEachIndexed { index, line ->
val isControl = index == bioLines.lines.lastIndex && (bioLines.more || bioLines.less)
Text(
text = line,
style = style,
color = color,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
when {
isControl && bioLines.more -> Modifier.testTag(ARTIST_BIO_MORE_TAG).clickable(onClick = onExpand)
isControl && bioLines.less -> Modifier.testTag(ARTIST_BIO_LESS_TAG).clickable(onClick = onCollapse)
else -> Modifier
},
)
}
}
Row(
Modifier
.align(Alignment.TopEnd)
.offset(x = BIO_ACTION_END_OFFSET, y = -BIO_ACTION_OFFSET),
) {
ArtistActions(artist, starred, onToggleStar, onMore)
}
}
}
}
private fun bioLineWidth(
index: Int,
fullWidth: Int,
reserve: Int,
reserveLines: Int,
): Int = if (index < reserveLines) fullWidth - reserve else fullWidth
/** Splits text at every source line break, then wraps it to the width left by the actions. */
internal fun splitBio(
measurer: TextMeasurer,
text: String,
style: TextStyle,
fullWidth: Int,
reserve: Int,
reserveLines: Int,
): List<String> {
val lines = mutableListOf<String>()
text.split('\n').forEach { sourceLine ->
val words = sourceLine.trim().split(' ').filter { it.isNotEmpty() }
if (words.isEmpty()) {
lines += ""
return@forEach
}
var current = ""
for (word in words) {
val candidate = if (current.isEmpty()) word else "$current $word"
val available = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
current = candidate
} else if (current.isEmpty()) {
lines += candidate
} else {
lines += current
current = word
}
}
if (current.isNotEmpty()) lines += current
}
while (lines.isNotEmpty() && lines.last().isBlank()) lines.removeAt(lines.lastIndex)
return lines.ifEmpty { listOf("") }
}
internal fun splitBioExpanded(
measurer: TextMeasurer,
text: String,
style: TextStyle,
fullWidth: Int,
reserve: Int,
reserveLines: Int,
lessText: String,
): BioLines {
val lines = splitBio(measurer, text, style, fullWidth, reserve, reserveLines).mapTo(mutableListOf()) { AnnotatedString(it) }
appendBioControl(lines, lessText, measurer, style, fullWidth, reserve, reserveLines)
return BioLines(lines, more = false, less = true)
}
private fun appendBioControl(
lines: MutableList<AnnotatedString>,
label: String,
measurer: TextMeasurer,
style: TextStyle,
fullWidth: Int,
reserve: Int,
reserveLines: Int,
) {
while (lines.isNotEmpty() && lines.last().text.isBlank()) lines.removeAt(lines.lastIndex)
val control =
buildAnnotatedString {
append(" ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(label) }
}
if (lines.isEmpty()) {
lines += control
return
}
val lastIndex = lines.lastIndex
val available = bioLineWidth(lastIndex, fullWidth, reserve, reserveLines)
val combined =
buildAnnotatedString {
append(lines[lastIndex])
append(control)
}
if (measurer.measure(combined, style, maxLines = 1).size.width <= available) {
lines[lastIndex] = combined
return
}
val words = lines[lastIndex].text.split(' ').filter { it.isNotEmpty() }
if (words.size > 1) {
val tailLine =
buildAnnotatedString {
append(words.last())
append(control)
}
if (measurer.measure(tailLine, style, maxLines = 1).size.width <= available) {
lines[lastIndex] = AnnotatedString(words.dropLast(1).joinToString(" "))
lines += tailLine
return
}
}
lines += control
}
internal data class BioLines(
val lines: List<AnnotatedString>,
val more: Boolean,
val less: Boolean = false,
)
/** Collapses text to [maxLines] lines, appending a bold More only when it does not fit. */
internal fun splitBioCollapsed(
measurer: TextMeasurer,
text: String,
style: TextStyle,
fullWidth: Int,
reserve: Int,
reserveLines: Int,
moreText: String,
maxLines: Int = 2,
): BioLines {
val words =
text
.replace("\n\n", " ")
.replace('\n', ' ')
.split(' ')
.filter { it.isNotEmpty() }
if (words.isEmpty()) return BioLines(listOf(AnnotatedString("")), more = false)
val lines = mutableListOf<AnnotatedString>()
var index = 0
while (index < words.size && lines.size < maxLines - 1) {
var current = ""
while (index < words.size) {
val candidate = if (current.isEmpty()) words[index] else "$current ${words[index]}"
val available = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= available) {
current = candidate
index++
} else if (current.isEmpty()) {
current = words[index]
index++
break
} else {
break
}
}
lines += AnnotatedString(current)
}
if (index >= words.size) {
return BioLines(lines, more = false)
}
val remainingAvailable = bioLineWidth(lines.size, fullWidth, reserve, reserveLines)
val remaining = words.drop(index).joinToString(" ")
if (measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= remainingAvailable) {
lines += AnnotatedString(remaining)
return BioLines(lines, more = false)
}
val more =
buildAnnotatedString {
append("… ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(moreText) }
}
val moreWidth =
measurer.measure(more, style, maxLines = 1).size.width +
measurer.measure(AnnotatedString(" "), style, maxLines = 1).size.width
val lastAvailable = (bioLineWidth(lines.size, fullWidth, reserve, reserveLines) - moreWidth).coerceAtLeast(1)
var truncated = ""
while (index < words.size) {
val candidate = if (truncated.isEmpty()) words[index] else "$truncated ${words[index]}"
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= lastAvailable) {
truncated = candidate
index++
} else {
break
}
}
val lastLine =
buildAnnotatedString {
if (truncated.isNotEmpty()) append("$truncated ")
append("… ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(moreText) }
}
lines += lastLine
return BioLines(lines, more = true)
}
@Composable
private fun ArtistSectionHeader(
text: String,
icon: ImageVector,
topGap: Dp = 0.dp,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = topGap, bottom = 4.dp),
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(16.dp))
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ArtistActions(
artist: Artist?,
starred: Boolean,
onToggleStar: (() -> Unit)?,
onMore: () -> Unit,
) {
if (artist == null) return
IconButton(
onClick = { onToggleStar?.invoke() },
enabled = onToggleStar != null,
) {
Icon(
imageVector = if (starred) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription =
stringResource(
if (starred) R.string.actions_unstar else R.string.actions_star,
),
tint =
if (starred) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
IconButton(onClick = onMore) {
Icon(Icons.Rounded.MoreHoriz, contentDescription = stringResource(R.string.more_options))
}
}
@Composable
private fun ArtistAlbumCard(
album: Album,
index: Int,
coverArt: (String?, Boolean) -> CoverArtRef?,
downloadStatus: ListDownloadStatus?,
onClick: () -> Unit,
onLongClick: (CoverArtRef?) -> Unit,
onSizeChanged: (Float) -> Unit,
) {
val art = coverArt(album.coverArt, false)
val thumbnail = coverArt(album.coverArt, true)
PrefetchArtworkSeeds(thumbnail)
Column(
modifier =
Modifier
.padding(
start = if (index % 2 == 0) 16.dp else 0.dp,
end = if (index % 2 == 1) 16.dp else 0.dp,
).combinedClickable(
onClick = onClick,
onLongClick = { onLongClick(thumbnail) },
).onSizeChanged { onSizeChanged(it.height.toFloat()) },
) {
Box(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
) {
CoverArt(
ref = art,
name = album.name,
thumbnailRef = thumbnail,
showPlaceholder = art == null,
modifier = Modifier.fillMaxSize(),
)
ListDownloadIndicator(
status = downloadStatus,
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
scrim = true,
)
}
Text(
text = album.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
Text(
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ArtistTitle(
text: String,
style: TextStyle,
color: Color,
endReserve: Dp,
maxLines: Int,
modifier: Modifier = Modifier,
) {
val measurer = rememberTextMeasurer()
@@ -512,7 +1016,10 @@ private fun ArtistTitle(
BoxWithConstraints(modifier) {
val fullWidth = with(density) { maxWidth.roundToPx() }
val reserve = with(density) { endReserve.roundToPx() }
val lines = remember(text, style, fullWidth, reserve) { splitTitle(measurer, text, style, fullWidth, reserve) }
val lines =
remember(text, style, fullWidth, reserve, maxLines) {
splitTitle(measurer, text, style, fullWidth, reserve, maxLines)
}
Column {
lines.forEachIndexed { index, line ->
Text(
@@ -537,6 +1044,7 @@ internal fun splitTitle(
style: TextStyle,
fullWidth: Int,
reserve: Int,
maxLines: Int = ARTIST_NAME_MAX_LINES,
): List<String> {
if (text.isBlank()) return listOf(text)
if (measurer.measure(AnnotatedString(text), style, maxLines = 1).size.width <= fullWidth - reserve) {
@@ -544,18 +1052,36 @@ internal fun splitTitle(
}
val words = text.split(' ').filter { it.isNotEmpty() }
if (words.size <= 1) return listOf(text)
var count = 0
var acc = ""
for (i in words.indices) {
val candidate = if (acc.isEmpty()) words[i] else "$acc ${words[i]}"
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
acc = candidate
count = i + 1
} else {
val lines = mutableListOf<String>()
var index = 0
while (index < words.size && lines.size < maxLines) {
val remaining = words.subList(index, words.size).joinToString(" ")
if (lines.size == maxLines - 1 ||
measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= fullWidth - reserve
) {
lines.add(remaining)
break
}
var end = index
var acc = ""
while (end < words.size) {
val candidate = if (acc.isEmpty()) words[end] else "$acc ${words[end]}"
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
acc = candidate
end++
} else {
break
}
}
if (end == index) {
acc = words[index]
end = index + 1
} else if (end >= words.size && end - index > 1) {
end -= 1
acc = words.subList(index, end).joinToString(" ")
}
lines.add(acc)
index = end
}
if (count >= words.size) count = words.size - 1
if (count < 1) count = 1
return listOf(words.take(count).joinToString(" "), words.drop(count).joinToString(" "))
return lines
}
@@ -2,8 +2,9 @@ package com.subtracks.ui.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadList
@@ -12,9 +13,10 @@ import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -36,21 +38,26 @@ class ArtistDetailViewModel(
) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
val artist: Flow<Artist?> = sourceId.flatMapLatest { libraryRepository.artist(it, artistId) }
val artist: StateFlow<Artist?> =
sourceId
.flatMapLatest { libraryRepository.artist(it, artistId) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val artistName: Flow<String?> =
sourceId.flatMapLatest { id ->
combine(
libraryRepository.artist(id, artistId),
libraryRepository.artistName(id, artistId),
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
}
val artistName: StateFlow<String?> =
sourceId
.flatMapLatest { id ->
combine(
libraryRepository.artist(id, artistId),
libraryRepository.artistName(id, artistId),
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val offline: StateFlow<Boolean> = sourceRepository.offline
val albums: Flow<List<Album>> =
val albums: StateFlow<ArtistAlbums?> =
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val downloadStatus: StateFlow<ListDownloadStatus> =
sourceId
@@ -62,6 +69,55 @@ class ArtistDetailViewModel(
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
val artistBio: StateFlow<ArtistBio?> =
sourceId
.flatMapLatest { libraryRepository.artistBio(it, artistId) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _bioLoading = MutableStateFlow(true)
val bioLoading: StateFlow<Boolean> = _bioLoading
private val _bioUnavailable = MutableStateFlow(false)
val bioUnavailable: StateFlow<Boolean> = _bioUnavailable
private val _bioExpanded = MutableStateFlow(false)
val bioExpanded: StateFlow<Boolean> = _bioExpanded
private var bioRefresh: Job? = null
fun refreshBio() {
if (bioRefresh?.isActive == true) return
bioRefresh =
viewModelScope.launch {
_bioUnavailable.value = false
if (sourceRepository.offline.value) {
_bioLoading.value = false
_bioUnavailable.value = true
return@launch
}
val id = sourceRepository.activeSourceIdOnce()
if (id == null) {
_bioLoading.value = false
_bioUnavailable.value = true
return@launch
}
_bioLoading.value = true
try {
_bioUnavailable.value = libraryRepository.refreshArtistBio(id, artistId).isFailure
} finally {
_bioLoading.value = false
}
}
}
fun expandBio() {
_bioExpanded.value = true
}
fun collapseBio() {
_bioExpanded.value = false
}
suspend fun downloadedBytes(
list: DownloadList,
refId: String,
@@ -84,30 +140,36 @@ class ArtistDetailViewModel(
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
private val _heroArt = MutableStateFlow<ArtistArt?>(null)
val heroArt: StateFlow<ArtistArt?> = _heroArt
init {
viewModelScope.launch {
artist
sourceRepository.activeSource
.filterNotNull()
.collectLatest { loaded ->
val (full, thumbnail) = resolveArt(loaded)
_art.value = full
_artThumbnail.value = thumbnail
.flatMapLatest { sourceId ->
combine(
libraryRepository.artist(sourceId, artistId),
libraryRepository.artistCoverArts(sourceId, artistId),
sourceRepository.offline,
) { loaded, covers, offline -> ArtistArtInput(loaded, artistId, offline, sourceId, covers) }
}.collectLatest { input ->
val resolved = resolveArtistArtInput(input)
if (resolved == null) {
_art.value = null
_artThumbnail.value = null
_heroArt.value = null
return@collectLatest
}
val hero = sourceRepository.artistArt(resolved, forIcon = false)
val icon = sourceRepository.artistArt(resolved, forIcon = true)
_art.value = hero.image ?: hero.covers.firstOrNull()
_artThumbnail.value = icon.image ?: icon.covers.firstOrNull()
_heroArt.value = hero
}
}
}
private suspend fun resolveArt(artist: Artist): Pair<CoverArtRef?, CoverArtRef?> {
if (artist.coverArt == null) return null to null
repeat(3) { attempt ->
val full = sourceRepository.coverArt(artist.coverArt)
if (full != null) {
return full to sourceRepository.coverArt(artist.coverArt, thumbnail = true)
}
if (attempt < 2) delay(300)
}
return null to null
}
fun coverArt(
coverArt: String?,
thumbnail: Boolean,
@@ -119,3 +181,35 @@ class ArtistDetailViewModel(
}
}
}
internal data class ArtistArtInput(
val artist: Artist?,
val artistId: String,
val offline: Boolean,
val sourceId: Long,
val covers: List<String>,
)
internal fun resolveArtistArtInput(input: ArtistArtInput): Artist? {
val artist = input.artist
if (input.covers.isEmpty()) return artist
if (artist == null) {
return Artist(
sourceId = input.sourceId,
id = input.artistId,
name = "",
albumCount = 0,
starred = null,
coverArts = input.covers,
downloadedCoverArts = if (input.offline) input.covers else emptyList(),
)
}
return if (input.offline) {
artist.copy(
coverArts = input.covers,
downloadedCoverArts = artist.downloadedCoverArts.ifEmpty { input.covers },
)
} else {
artist.copy(coverArts = input.covers)
}
}
@@ -29,7 +29,8 @@ import com.subtracks.R
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ArtistIcon
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.ListDownloadIndicator
@@ -55,6 +56,7 @@ fun ArtistsContent(
onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
scrollToTopKey: Any? = null,
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
) {
when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
@@ -82,7 +84,8 @@ fun ArtistsContent(
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val artist = items[index]
if (artist != null) {
PrefetchArtworkSeeds(coverArt(artist.coverArt, true))
val art = artistArt(artist)
PrefetchArtworkSeeds(art.primary)
ListItem(
headlineContent = {
Text(
@@ -104,9 +107,9 @@ fun ArtistsContent(
)
},
leadingContent = {
CoverArt(
ref = coverArt(artist.coverArt, true),
name = artist.name,
ArtistIcon(
artist = artist,
art = art,
modifier = Modifier.size(48.dp).clip(CircleShape),
)
},
@@ -117,7 +120,7 @@ fun ArtistsContent(
Modifier.combinedClickable(
onClick = { onArtistClick(artist) },
onLongClick = {
onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true), downloadStatuses[artist.id]))
onLongClick(MenuTarget.Artist(artist, art.primary, downloadStatuses[artist.id]))
},
),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -1,10 +1,12 @@
package com.subtracks.ui.library
import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -24,6 +26,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -42,6 +45,7 @@ import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.FloatingActionButton
@@ -70,7 +74,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
@@ -92,12 +95,15 @@ import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.PagingData
@@ -118,6 +124,7 @@ import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
@@ -272,6 +279,7 @@ fun LibraryRoute(
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
val downloads = downloadsState?.value ?: emptyMap()
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
val itemActions =
@@ -305,13 +313,17 @@ fun LibraryRoute(
artists = viewModel.artists,
playlists = viewModel.playlists,
coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlaylistClick = onPlaylistClick,
onSync = viewModel::sync,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
onItemLongClick = {
focusManager.clearFocus()
contextMenuHost?.show(it, itemActions)
},
listQuery = displayQuery,
resetKeys = resetKeys,
offline = offline,
@@ -390,6 +402,7 @@ fun LibraryScreen(
onSync: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
onOpenSearch: () -> Unit = {},
offline: Boolean = false,
onExitOffline: () -> Unit = {},
@@ -424,9 +437,14 @@ fun LibraryScreen(
var searchActive by rememberSaveable { mutableStateOf(false) }
var topRequest by remember { mutableIntStateOf(0) }
val searchFocus = remember { FocusRequester() }
val closeSearch = {
searchActive = false
onSearchChange("")
}
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
DismissOnRequest(dismissals) { showOptions = false }
BackHandler(enabled = searchActive, onBack = closeSearch)
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.collect { page ->
@@ -445,14 +463,8 @@ fun LibraryScreen(
}
}
fun dismissSearch() {
if (searchActive) {
searchActive = false
onSearchChange("")
}
}
val density = LocalDensity.current
val focusManager = LocalFocusManager.current
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
@@ -503,7 +515,7 @@ fun LibraryScreen(
coverArt,
listBottomInset,
{
dismissSearch()
focusManager.clearFocus()
onAlbumClick(it)
},
onLongClick = onItemLongClick,
@@ -523,7 +535,7 @@ fun LibraryScreen(
coverArt,
listBottomInset,
{
dismissSearch()
focusManager.clearFocus()
onArtistClick(it)
},
onLongClick = onItemLongClick,
@@ -534,6 +546,7 @@ fun LibraryScreen(
onSync = onSync,
downloadStatuses = artistDownloads,
scrollToTopKey = scrollToTopKey,
artistArt = artistArt,
)
}
@@ -543,7 +556,7 @@ fun LibraryScreen(
coverArt,
listBottomInset,
{
dismissSearch()
focusManager.clearFocus()
onPlaylistClick(it)
},
onLongClick = onItemLongClick,
@@ -583,8 +596,6 @@ fun LibraryScreen(
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
},
onOpenSettings = onOpenSettings,
offline = offline,
onExitOffline = onExitOffline,
artwork = artwork,
syncing = syncing,
)
@@ -595,10 +606,7 @@ fun LibraryScreen(
SearchField(
value = search,
onValueChange = onSearchChange,
onClose = {
searchActive = false
onSearchChange("")
},
onClose = closeSearch,
focusRequester = searchFocus,
modifier =
Modifier
@@ -609,11 +617,29 @@ fun LibraryScreen(
}
if (!searchActive) {
val fabContainerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh
val fabContentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground
if (offline) {
FilledTonalIconButton(
onClick = onExitOffline,
modifier =
Modifier
.align(Alignment.BottomStart)
.padding(start = 16.dp, bottom = tabBarHeight + 16.dp)
.size(48.dp),
) {
Icon(
imageVector = Icons.Rounded.CloudOff,
contentDescription = stringResource(R.string.offline_mode_status),
modifier = Modifier.size(24.dp).offset(y = (-0.75).dp),
)
}
}
val searchFab = selectedTab == LibraryTab.Home
FloatingActionButton(
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground,
containerColor = fabContainerColor,
contentColor = fabContentColor,
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
) {
@@ -632,11 +658,18 @@ fun LibraryScreen(
modifier =
Modifier
.align(Alignment.TopEnd)
.offset(x = 4.dp, y = (-4).dp)
.size(8.dp)
.clip(CircleShape)
.background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary),
)
.offset(x = 1.dp, y = 2.5.dp)
.size(11.dp)
.background(fabContainerColor, CircleShape),
contentAlignment = Alignment.Center,
) {
Box(
modifier =
Modifier
.size(7.dp)
.background(fabContentColor, CircleShape),
)
}
}
}
}
@@ -696,7 +729,7 @@ private fun ListOptionsSheet(
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -818,6 +851,7 @@ private fun ListOptionsSheet(
private val BOTTOM_CLEARANCE = 80.dp
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
private val TAB_STRIP_LEADING_GAP = 8.dp
private val TAB_ICON_SIZE = 24.dp
private val TAB_VERTICAL_PADDING = 6.dp
private val TOUCH_TARGET = 48.dp
@@ -850,13 +884,12 @@ internal fun LibraryTabs(
pagerState: PagerState,
onTabSelected: (LibraryTab) -> Unit,
onOpenSettings: () -> Unit,
offline: Boolean = false,
onExitOffline: () -> Unit = {},
artwork: ArtworkColors?,
syncing: Boolean = false,
) {
val indicatorStretch = 18.dp
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val bounds = remember { mutableStateMapOf<Int, Rect>() }
val page = pagerState.currentPage
val fraction = pagerState.currentPageOffsetFraction
@@ -888,79 +921,113 @@ internal fun LibraryTabs(
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() }
val scrollState = rememberScrollState()
var viewportWidth by remember { mutableIntStateOf(0) }
var contentWidth by remember { mutableIntStateOf(0) }
var revealed by remember { mutableStateOf(false) }
val leadingGap = with(density) { TAB_STRIP_LEADING_GAP.toPx() }
LaunchedEffect(pagerState.settledPage, viewportWidth, contentWidth, layoutDirection, bounds[pagerState.settledPage]) {
if (viewportWidth == 0) return@LaunchedEffect
val settled = bounds[pagerState.settledPage] ?: return@LaunchedEffect
val start = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.right else leadingGap + settled.left
val end = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.left else leadingGap + settled.right
val target =
when {
start - leadingGap < scrollState.value -> start - leadingGap
end > scrollState.value + viewportWidth -> end - viewportWidth
else -> null
}
if (target != null) {
val offset = target.roundToInt().coerceIn(0, scrollState.maxValue)
if (revealed) scrollState.animateScrollTo(offset) else scrollState.scrollTo(offset)
}
revealed = true
}
Box(Modifier.fillMaxWidth()) {
Box(
modifier =
Modifier
.fillMaxWidth()
.background(headerColor)
.padding(horizontal = 8.dp, vertical = 2.dp),
.padding(top = 2.dp, end = 8.dp, bottom = 2.dp),
) {
Box(
modifier =
Modifier.drawBehind {
if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) {
val height = tabContentHeightPx.coerceAtMost(current.height)
drawRoundRect(
color = indicatorColor,
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
size = Size(indicatorRight - indicatorLeft, height),
cornerRadius = CornerRadius(8.dp.toPx()),
)
}
},
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier =
Modifier
.weight(1f)
.onSizeChanged { viewportWidth = it.width }
.horizontalScroll(scrollState)
.onSizeChanged { contentWidth = it.width },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
LibraryTab.entries.forEachIndexed { index, tab ->
TabButton(
tab = tab,
artwork = artwork,
progress =
when (index) {
page -> 1f - transition
page + (if (fraction >= 0f) 1 else -1) -> transition
else -> 0f
Spacer(Modifier.width(TAB_STRIP_LEADING_GAP))
Box(
modifier =
Modifier
.drawBehind {
if (current != null &&
indicatorLeft != null &&
indicatorRight != null &&
indicatorRight > indicatorLeft
) {
val height = tabContentHeightPx.coerceAtMost(current.height)
drawRoundRect(
color = indicatorColor,
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
size = Size(indicatorRight - indicatorLeft, height),
cornerRadius = CornerRadius(8.dp.toPx()),
)
}
},
indicatorLeft = indicatorLeft,
indicatorRight = indicatorRight,
onClick = { onTabSelected(tab) },
modifier =
Modifier.onGloballyPositioned { coordinates ->
bounds[index] = coordinates.boundsInParent()
},
)
}
Spacer(Modifier.weight(1f))
if (offline) {
IconButton(onClick = onExitOffline) {
Icon(
imageVector = Icons.Rounded.CloudOff,
contentDescription = stringResource(R.string.offline_mode_status),
tint = MaterialTheme.colorScheme.onBackground,
)
}
}
IconButton(onClick = onOpenSettings) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.navigation_tabs_settings),
tint = MaterialTheme.colorScheme.onBackground,
)
if (syncing) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
LibraryTab.entries.forEachIndexed { index, tab ->
TabButton(
tab = tab,
artwork = artwork,
progress =
when (index) {
page -> 1f - transition
page + (if (fraction >= 0f) 1 else -1) -> transition
else -> 0f
},
indicatorLeft = indicatorLeft,
indicatorRight = indicatorRight,
onClick = { onTabSelected(tab) },
modifier =
Modifier.onGloballyPositioned { coordinates ->
bounds[index] = coordinates.boundsInParent()
},
)
}
}
}
}
IconButton(onClick = onOpenSettings) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.navigation_tabs_settings),
tint = MaterialTheme.colorScheme.onBackground,
)
if (syncing) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
@@ -23,6 +23,8 @@ import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager
import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -211,6 +213,11 @@ class LibraryViewModel(
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun sync() {
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
}
@@ -55,7 +55,7 @@ fun PlaylistDetailRoute(
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
playbackController: PlaybackController = koinInject(),
) {
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
val playlist by viewModel.playlist.collectAsStateWithLifecycle()
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
@@ -138,6 +138,7 @@ fun PlaylistDetailScreen(
artwork = artwork,
title = playlist?.name.orEmpty(),
onBack = onBack,
contentReady = songs.itemCount > 0,
header = { controlsModifier, topInset ->
HeroHeader(
art = coverArt(playlist?.coverArt, false),
@@ -37,7 +37,10 @@ class PlaylistDetailViewModel(
) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
val playlist: Flow<Playlist?> = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) }
val playlist: StateFlow<Playlist?> =
sourceId
.flatMapLatest { libraryRepository.playlist(it, playlistId) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val songs: Flow<PagingData<PlaylistSongItem>> =
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
@@ -61,6 +61,7 @@ import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.artistSubtitle
private val TRACK_COLUMN_WIDTH = 24.dp
private val TIME_COLUMN_WIDTH = 36.dp
@@ -146,7 +147,7 @@ fun SongRow(
},
supportingContent = {
Text(
text = song.artist.orEmpty().ifEmpty { "\u00A0" },
text = song.artistSubtitle().orEmpty().ifEmpty { "\u00A0" },
color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -44,6 +44,7 @@ import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.playback.PlaybackState
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.artistSubtitle
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.playerSurfaceColor
@@ -139,7 +140,7 @@ fun MiniPlayer(
modifier = Modifier.basicMarquee(),
)
Text(
text = item.artist ?: item.album.orEmpty(),
text = artistSubtitle(item.artist, item.artists) ?: item.album.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -58,9 +58,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.keepScreenOn
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
@@ -77,6 +77,7 @@ import com.subtracks.R
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.source.StarType
@@ -84,10 +85,12 @@ import com.subtracks.playback.PlaybackController
import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem
import com.subtracks.playback.RepeatMode
import com.subtracks.ui.components.ArtistChooserSheet
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.artistSubtitle
import com.subtracks.ui.components.prefetchArtworkRatio
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkSeedCache
@@ -111,6 +114,10 @@ private const val SWIPE_SETTLE_MS = 180
private const val SWIPE_EDGE_RESISTANCE = 0.3f
private const val HAND_BACK_TIMEOUT_MS = 750L
// The strip is unclipped, so this spacing is what keeps the neighbours off-screen, and it has to
// clear the content inset plus a tile's shadow. It doubles as the gap between tiles while dragging.
private val TILE_GAP = 40.dp
data class NowPlayingArt(
val id: String?,
val ref: CoverArtRef?,
@@ -188,14 +195,18 @@ fun NowPlayingRoute(
controller: PlaybackController = koinInject(),
libraryRepository: LibraryRepository = koinInject(),
downloadRepository: DownloadRepository = koinInject(),
userPreferences: UserPreferences = koinInject(),
) {
val state by controller.state.collectAsStateWithLifecycle()
val keepScreenOn by remember { userPreferences.keepScreenOn() }.collectAsStateWithLifecycle(initialValue = false)
val positionMs by controller.positionMs.collectAsStateWithLifecycle()
val sourceId = state.context?.sourceId
val songId = state.item?.id
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
val download = songId?.let { downloads[it] }
val scope = rememberCoroutineScope()
var showArtistChooser by remember { mutableStateOf(false) }
LaunchedEffect(songId) { showArtistChooser = false }
val song by
produceState<Song?>(null, sourceId, songId) {
if (sourceId != null && songId != null) {
@@ -234,7 +245,7 @@ fun NowPlayingRoute(
name = item.title,
)
var adjacent by remember { mutableStateOf(AdjacentArt()) }
LaunchedEffect(state.item?.id, state.layout) {
LaunchedEffect(state.item?.id, state.layout, state.repeat) {
if (state.item == null) {
adjacent = AdjacentArt()
return@LaunchedEffect
@@ -265,12 +276,32 @@ fun NowPlayingRoute(
onRepeat = controller::cycleRepeat,
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } },
onArtistClick =
song
?.takeIf { it.artistRefs.isNotEmpty() }
?.let {
{
val artists = it.artistRefs
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
}
},
starred = song?.starred != null,
onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } },
onSeek = controller::seekTo,
keepScreenOn = keepScreenOn,
modifier = modifier,
)
if (showArtistChooser) {
ArtistChooserSheet(
artists = song?.artistRefs.orEmpty(),
onArtistClick = { artist ->
showArtistChooser = false
onViewArtist(artist.id)
},
onDismiss = { showArtistChooser = false },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -287,6 +318,7 @@ fun NowPlayingScreen(
onNext: () -> Unit,
onPrevious: () -> Unit,
modifier: Modifier = Modifier,
keepScreenOn: Boolean = false,
onSkipPrevious: () -> Unit = onPrevious,
onShuffle: () -> Unit = {},
onRepeat: () -> Unit = {},
@@ -329,6 +361,7 @@ fun NowPlayingScreen(
val strip = frozenStrip ?: liveStrip
val canGoNext = strip.getOrNull(2) != null
val canGoPrevious = strip.getOrNull(0) != null
val stridePx = coverWidthPx + with(LocalDensity.current) { TILE_GAP.toPx() }
fun animateSwipe(target: Float) {
settleJob?.cancel()
@@ -340,7 +373,7 @@ fun NowPlayingScreen(
fun commitSwipe(direction: Int) {
frozenStrip = rotateStrip(strip, direction)
swipeOffsetX += direction * coverWidthPx
swipeOffsetX += direction * stridePx
if (direction > 0) onNext() else onSkipPrevious()
animateSwipe(0f)
}
@@ -362,7 +395,12 @@ fun NowPlayingScreen(
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(artwork) {
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
Box(
modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.then(if (keepScreenOn) Modifier.keepScreenOn() else Modifier),
) {
HeroGradient(
colors = artwork,
scrollPx = { 0f },
@@ -410,13 +448,14 @@ fun NowPlayingScreen(
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Deliberately unclipped: a tile that fills the width would lose its drop
// shadow. TILE_GAP keeps the neighbours off-screen instead.
Box(
modifier =
Modifier
.weight(1f)
.fillMaxWidth()
.padding(top = 2.dp)
.clipToBounds()
.onSizeChanged { coverWidthPx = it.width.toFloat() }
.draggable(
orientation = Orientation.Horizontal,
@@ -428,7 +467,7 @@ fun NowPlayingScreen(
delta < 0 && !canGoNext -> delta * SWIPE_EDGE_RESISTANCE
else -> delta
}
swipeOffsetX = (swipeOffsetX + resisted).coerceIn(-coverWidthPx, coverWidthPx)
swipeOffsetX = (swipeOffsetX + resisted).coerceIn(-stridePx, stridePx)
},
onDragStarted = {
dragging = true
@@ -484,7 +523,7 @@ fun NowPlayingScreen(
Modifier
.fillMaxSize()
.graphicsLayer {
translationX = (index - 1) * coverWidthPx + swipeOffsetX
translationX = (index - 1) * stridePx + swipeOffsetX
}.then(
when (index) {
0 -> Modifier.testTag(NOW_PLAYING_PREVIOUS_TAG)
@@ -538,7 +577,10 @@ fun NowPlayingScreen(
}
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
Text(
text = state.item?.artist.orEmpty(),
text =
state.item
?.let { artistSubtitle(it.artist, it.artists) }
.orEmpty(),
style = subtitleStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -32,6 +32,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -45,6 +46,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.ItemActions
@@ -85,6 +87,7 @@ fun SearchRoute(
val query by viewModel.query.collectAsStateWithLifecycle()
val results by viewModel.results.collectAsStateWithLifecycle()
val playback by playbackController.state.collectAsStateWithLifecycle()
val focusManager = LocalFocusManager.current
var filterMask by rememberSaveable { mutableIntStateOf(allFilterMask()) }
val actions =
ItemActions(
@@ -104,13 +107,29 @@ fun SearchRoute(
if (next != 0) filterMask = next
},
coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id,
onBack = onBack,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlaylistClick = onPlaylistClick,
onSongClick = viewModel::play,
onLongClick = { contextMenuHost?.show(it, actions) },
onAlbumClick = {
focusManager.clearFocus()
onAlbumClick(it)
},
onArtistClick = {
focusManager.clearFocus()
onArtistClick(it)
},
onPlaylistClick = {
focusManager.clearFocus()
onPlaylistClick(it)
},
onSongClick = {
focusManager.clearFocus()
viewModel.play(it)
},
onLongClick = {
focusManager.clearFocus()
contextMenuHost?.show(it, actions)
},
)
}
@@ -129,6 +148,7 @@ fun SearchScreen(
filters: Set<SearchFilter> = ALL_FILTERS,
onToggleFilter: (SearchFilter) -> Unit = {},
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
playingSongId: String? = null,
onBack: () -> Unit = {},
onAlbumClick: (Album) -> Unit = {},
@@ -198,7 +218,7 @@ fun SearchScreen(
contentPadding = PaddingValues(bottom = 24.dp),
) {
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick)
if (showArtists) artistResults(results.artists, artistsLabel, coverArt, onArtistClick, onLongClick)
if (showArtists) artistResults(results.artists, artistsLabel, artistArt, onArtistClick, onLongClick)
if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
}
@@ -291,19 +311,20 @@ private fun LazyListScope.albumResults(
private fun LazyListScope.artistResults(
artists: List<Artist>,
header: String,
coverArt: (String?, Boolean) -> CoverArtRef?,
artistArt: (Artist) -> ArtistArt,
onClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
) {
if (artists.isEmpty()) return
resultHeader(header)
items(artists, key = { "artist-${it.id}" }) { artist ->
val art = artistArt(artist)
ArtistListRow(
artist = artist,
coverArt = coverArt,
art = art,
info = null,
onClick = { onClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
)
}
}
@@ -12,6 +12,8 @@ import com.subtracks.data.model.Song
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import com.subtracks.ui.home.playSongInContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -74,6 +76,11 @@ class SearchViewModel(
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun play(song: Song) {
viewModelScope.playSongInContext(libraryRepository, playbackController, song)
}
@@ -0,0 +1,45 @@
package com.subtracks.ui.settings
import android.content.res.Resources
import com.subtracks.R
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.ReplayGainMode
import kotlin.math.roundToInt
internal const val REPLAY_GAIN_PREAMP_STEP_DB = 0.5f
internal val REPLAY_GAIN_PREAMP_STEPS =
((MAX_REPLAY_GAIN_PREAMP_DB - MIN_REPLAY_GAIN_PREAMP_DB) / REPLAY_GAIN_PREAMP_STEP_DB).toInt() - 1
// Compose Material 3's Slider snaps in pixel space and converts back, so the value it reports is
// near, but not on, the grid it was configured with (for example -0.99999905 for -1.0). Snap before
// showing or storing it.
internal fun snapReplayGainPreampDb(value: Float): Float =
((value / REPLAY_GAIN_PREAMP_STEP_DB).roundToInt() * REPLAY_GAIN_PREAMP_STEP_DB)
.coerceIn(MIN_REPLAY_GAIN_PREAMP_DB, MAX_REPLAY_GAIN_PREAMP_DB)
internal fun stepReplayGainPreampDb(
value: Float,
steps: Int,
): Float = snapReplayGainPreampDb(value + steps * REPLAY_GAIN_PREAMP_STEP_DB)
internal fun replayGainModeLabel(
resources: Resources,
mode: ReplayGainMode,
): String =
when (mode) {
ReplayGainMode.Off -> resources.getString(R.string.settings_replay_gain_mode_off)
ReplayGainMode.Track -> resources.getString(R.string.settings_replay_gain_mode_track)
ReplayGainMode.Album -> resources.getString(R.string.settings_replay_gain_mode_album)
ReplayGainMode.Auto -> resources.getString(R.string.settings_replay_gain_mode_auto)
}
internal fun replayGainPreampLabel(
resources: Resources,
decibels: Float,
): String {
val sign = if (decibels > 0f) "+" else ""
val magnitude = String.format(resources.configuration.locales[0], "%.1f", decibels)
return resources.getString(R.string.settings_replay_gain_preamp_value, "$sign$magnitude")
}
@@ -28,6 +28,7 @@ import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Remove
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -39,6 +40,8 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
@@ -46,6 +49,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -59,6 +63,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -66,6 +71,10 @@ import com.subtracks.BuildConfig
import com.subtracks.R
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.ReplayGainMode
import com.subtracks.data.prefs.SortNameMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.SyncMode
import com.subtracks.log.Log
@@ -84,7 +93,17 @@ private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "f
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency, SyncInterval, SyncMode }
private enum class SettingsDialog {
WifiQuality,
MobileQuality,
DownloadQuality,
SyncConcurrency,
SyncInterval,
SyncMode,
ReplayGain,
ReplayGainPreamp,
SortNames,
}
@Composable
fun SettingsRoute(
@@ -106,8 +125,12 @@ fun SettingsRoute(
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
val keepScreenOn by viewModel.keepScreenOn.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
val sortNameMode by viewModel.sortNameMode.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
SettingsScreen(
@@ -121,6 +144,10 @@ fun SettingsRoute(
downloadQuality = downloadQuality,
downloadOverMetered = downloadOverMetered,
scrobbling = scrobbling,
replayGainMode = replayGainMode,
replayGainPreamp = replayGainPreamp,
sortNameMode = sortNameMode,
keepScreenOn = keepScreenOn,
offline = offline,
verboseLogging = verboseLogging,
onSelectSource = viewModel::selectSource,
@@ -133,6 +160,10 @@ fun SettingsRoute(
onDownloadQualityChange = viewModel::setDownloadQuality,
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
onScrobblingChange = viewModel::setScrobbling,
onReplayGainModeChange = viewModel::setReplayGainMode,
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
onSortNameModeChange = viewModel::setSortNameMode,
onKeepScreenOnChange = viewModel::setKeepScreenOn,
onOfflineChange = viewModel::setOfflineMode,
onVerboseLoggingChange = viewModel::setVerboseLogging,
onAddServer = onAddServer,
@@ -172,10 +203,14 @@ fun SettingsScreen(
downloadOverMetered: Boolean,
scrobbling: Boolean,
modifier: Modifier = Modifier,
keepScreenOn: Boolean = false,
offline: Boolean = false,
verboseLogging: Boolean = false,
syncMode: SyncMode = SyncMode.WifiOnly,
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
replayGainPreamp: Float = 0f,
sortNameMode: SortNameMode = SortNameMode.Name,
onSelectSource: (Long) -> Unit,
onEditServer: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit,
@@ -186,6 +221,10 @@ fun SettingsScreen(
onDownloadQualityChange: (StreamQuality) -> Unit,
onDownloadOverMeteredChange: (Boolean) -> Unit,
onScrobblingChange: (Boolean) -> Unit,
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
onReplayGainPreampChange: (Float) -> Unit = {},
onSortNameModeChange: (SortNameMode) -> Unit = {},
onKeepScreenOnChange: (Boolean) -> Unit = {},
onOfflineChange: (Boolean) -> Unit = {},
onVerboseLoggingChange: (Boolean) -> Unit = {},
onAddServer: () -> Unit,
@@ -292,14 +331,6 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
supportingContent = { Text(concurrencyLabel(LocalResources.current, syncConcurrency)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
@@ -316,6 +347,23 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
supportingContent = { Text(concurrencyLabel(LocalResources.current, syncConcurrency)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader(stringResource(R.string.settings_library_name)) }
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_sort_names_title)) },
supportingContent = { Text(sortNameModeLabel(LocalResources.current, sortNameMode)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SortNames },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader(stringResource(R.string.settings_music_name)) }
item {
ListItem(
@@ -337,6 +385,42 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_replay_gain_title)) },
supportingContent = { Text(replayGainModeLabel(LocalResources.current, replayGainMode)) },
modifier = Modifier.clickable { dialog = SettingsDialog.ReplayGain },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_replay_gain_preamp_title)) },
supportingContent = { Text(replayGainPreampLabel(LocalResources.current, replayGainPreamp)) },
modifier = Modifier.clickable { dialog = SettingsDialog.ReplayGainPreamp },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_music_options_keep_screen_on_title)) },
supportingContent = { Text(stringResource(R.string.settings_music_options_keep_screen_on_description)) },
trailingContent = {
Switch(
checked = keepScreenOn,
onCheckedChange = onKeepScreenOnChange,
colors =
SwitchDefaults.colors(
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
),
)
},
modifier = Modifier.clickable { onKeepScreenOnChange(!keepScreenOn) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader(stringResource(R.string.downloads_title)) }
item {
ListItem(
@@ -519,6 +603,30 @@ fun SettingsScreen(
)
}
SettingsDialog.ReplayGain -> {
ReplayGainModeDialog(
selected = replayGainMode,
onSelect = onReplayGainModeChange,
onDismiss = { dialog = null },
)
}
SettingsDialog.ReplayGainPreamp -> {
ReplayGainPreampDialog(
selected = replayGainPreamp,
onSelect = onReplayGainPreampChange,
onDismiss = { dialog = null },
)
}
SettingsDialog.SortNames -> {
SortNameModeDialog(
selected = sortNameMode,
onSelect = onSortNameModeChange,
onDismiss = { dialog = null },
)
}
null -> {
Unit
}
@@ -672,6 +780,164 @@ private fun SyncModeDialog(
)
}
private val sortNameModeOptions =
listOf(SortNameMode.Name, SortNameMode.SortName, SortNameMode.NonLatin)
private fun sortNameModeLabel(
resources: Resources,
mode: SortNameMode,
): String =
when (mode) {
SortNameMode.SortName -> resources.getString(R.string.sort_name_mode_sort_name)
SortNameMode.Name -> resources.getString(R.string.sort_name_mode_name)
SortNameMode.NonLatin -> resources.getString(R.string.sort_name_mode_non_latin)
}
@Composable
private fun SortNameModeDialog(
selected: SortNameMode,
onSelect: (SortNameMode) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableStateOf(selected) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_sort_names_title)) },
text = {
ChoiceGroup(
header = stringResource(R.string.settings_sort_names_prompt),
options = sortNameModeOptions.map { it to sortNameModeLabel(resources, it) },
selected = draft,
onSelect = { draft = it },
)
},
confirmButton = {
TextButton(
onClick = {
onSelect(draft)
onDismiss()
},
) { Text(stringResource(R.string.action_done)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
},
)
}
@Composable
private fun ReplayGainModeDialog(
selected: ReplayGainMode,
onSelect: (ReplayGainMode) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableStateOf(selected) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_replay_gain_title)) },
text = {
ChoiceGroup(
header = stringResource(R.string.settings_replay_gain_prompt),
options = ReplayGainMode.entries.map { it to replayGainModeLabel(resources, it) },
selected = draft,
onSelect = { draft = it },
)
},
confirmButton = {
TextButton(
onClick = {
onSelect(draft)
onDismiss()
},
) { Text(stringResource(R.string.action_done)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ReplayGainPreampDialog(
selected: Float,
onSelect: (Float) -> Unit,
onDismiss: () -> Unit,
) {
val resources = LocalResources.current
var draft by remember(selected) { mutableFloatStateOf(snapReplayGainPreampDb(selected)) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_replay_gain_preamp_title)) },
text = {
Column {
Text(
stringResource(R.string.settings_replay_gain_preamp_prompt),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
onClick = { draft = stepReplayGainPreampDb(draft, -1) },
enabled = draft > MIN_REPLAY_GAIN_PREAMP_DB,
) {
Icon(
Icons.Rounded.Remove,
contentDescription = stringResource(R.string.settings_replay_gain_preamp_decrease),
)
}
Slider(
value = draft,
onValueChange = { draft = snapReplayGainPreampDb(it) },
valueRange = MIN_REPLAY_GAIN_PREAMP_DB..MAX_REPLAY_GAIN_PREAMP_DB,
steps = REPLAY_GAIN_PREAMP_STEPS,
// Compose Material 3 draws one track tick per step, and at 59 steps they
// smear together, so the ticks are suppressed and the snapping kept.
track = { state -> SliderDefaults.Track(sliderState = state, drawTick = { _, _ -> }) },
modifier = Modifier.weight(1f),
)
IconButton(
onClick = { draft = stepReplayGainPreampDb(draft, 1) },
enabled = draft < MAX_REPLAY_GAIN_PREAMP_DB,
) {
Icon(
Icons.Rounded.Add,
contentDescription = stringResource(R.string.settings_replay_gain_preamp_increase),
)
}
}
Text(
replayGainPreampLabel(resources, draft),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
TextButton(
onClick = { draft = 0f },
enabled = draft != 0f,
) { Text(stringResource(R.string.settings_replay_gain_preamp_reset)) }
Spacer(Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
TextButton(
onClick = {
onSelect(draft)
onDismiss()
},
) { Text(stringResource(R.string.action_done)) }
}
},
)
}
@Composable
private fun QualityDialog(
title: String,
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Source
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
import com.subtracks.data.prefs.ReplayGainMode
import com.subtracks.data.prefs.SortNameMode
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.SyncMode
import com.subtracks.data.prefs.UserPreferences
@@ -65,6 +67,26 @@ class SettingsViewModel(
.scrobbling()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
val replayGainMode: StateFlow<ReplayGainMode> =
userPreferences
.replayGainMode()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Auto)
val replayGainPreamp: StateFlow<Float> =
userPreferences
.replayGainPreampDb()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
val sortNameMode: StateFlow<SortNameMode> =
userPreferences
.sortNameMode()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SortNameMode.Name)
val keepScreenOn: StateFlow<Boolean> =
userPreferences
.keepScreenOn()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val offline: StateFlow<Boolean> = sourceRepository.offline
val verboseLogging: StateFlow<Boolean> =
@@ -115,4 +137,20 @@ class SettingsViewModel(
fun setScrobbling(enabled: Boolean) {
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
}
fun setReplayGainMode(mode: ReplayGainMode) {
viewModelScope.launch { userPreferences.setReplayGainMode(mode) }
}
fun setReplayGainPreamp(value: Float) {
viewModelScope.launch { userPreferences.setReplayGainPreampDb(value) }
}
fun setKeepScreenOn(enabled: Boolean) {
viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) }
}
fun setSortNameMode(mode: SortNameMode) {
viewModelScope.launch { userPreferences.setSortNameMode(mode) }
}
}
+47
View File
@@ -35,6 +35,14 @@
<string name="resources_filter_artist">Artist</string>
<string name="artist_type_artist">Artist</string>
<string name="artist_type_album_artist">Album artist</string>
<string name="artist_section_albums">Albums</string>
<string name="artist_section_appearances">Appears on</string>
<string name="artist_bio_empty">No artist bio available.</string>
<string name="artist_bio_loading">Loading artist bio…</string>
<string name="artist_bio_unavailable">Artist bio unavailable.</string>
<string name="artist_bio_more">More</string>
<string name="artist_bio_less">Less</string>
<string name="artist_meta">%1$s · %2$s</string>
<string name="resources_filter_genre">Genre</string>
<string name="resources_filter_starred">Starred</string>
<string name="resources_song_list_delete_all_content">%1$s\n\nDeleting will free %2$s</string>
@@ -71,9 +79,29 @@
<string name="settings_about_verbose_logging">Verbose logging</string>
<string name="settings_about_verbose_logging_description">Log extra detail for troubleshooting</string>
<string name="settings_about_version">Version %1$s</string>
<string name="settings_library_name">Library</string>
<string name="settings_music_name">Playback</string>
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
<string name="settings_replay_gain_mode_album">Album gain</string>
<string name="settings_replay_gain_mode_auto">Auto</string>
<string name="settings_replay_gain_mode_off">Off</string>
<string name="settings_replay_gain_mode_track">Track gain</string>
<string name="settings_replay_gain_preamp_decrease">Lower preamp</string>
<string name="settings_replay_gain_preamp_increase">Raise preamp</string>
<string name="settings_replay_gain_preamp_prompt">Extra level on top of ReplayGain (tracks without peak data cannot be boosted)</string>
<string name="settings_replay_gain_preamp_reset">Reset</string>
<string name="settings_replay_gain_preamp_title">Preamp</string>
<string name="settings_replay_gain_preamp_value">%1$s dB</string>
<string name="settings_replay_gain_prompt">Normalise loudness using the ReplayGain tags from the server</string>
<string name="settings_replay_gain_title">ReplayGain</string>
<string name="settings_music_options_keep_screen_on_description">Keep the screen on while now playing is open</string>
<string name="settings_music_options_keep_screen_on_title">Keep screen on</string>
<string name="settings_sort_names_title">Sort names</string>
<string name="settings_sort_names_prompt">Choose how names are ordered in album and artist lists.</string>
<string name="sort_name_mode_sort_name">Use sort names</string>
<string name="sort_name_mode_name">Use regular names</string>
<string name="sort_name_mode_non_latin">Non-Latin names only</string>
<string name="settings_network_name">Network</string>
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
@@ -127,6 +155,7 @@
<string name="more_options">More options</string>
<string name="open_album">Open album</string>
<string name="open_artist">Open artist</string>
<string name="artist_chooser_title">Artists</string>
<string name="unknown_artist">Unknown artist</string>
<string name="unknown_album">Unknown album</string>
@@ -168,6 +197,24 @@
<string name="info_sample_rate_value">%1$d Hz</string>
<string name="info_channels">Channels</string>
<string name="info_close">Close</string>
<string name="info_artists">Artists</string>
<string name="info_artists_sort">Artists sort</string>
<string name="info_album_artists">Album artists</string>
<string name="info_album_artist_sort">Album artist sort</string>
<string name="info_sort_title">Sort title</string>
<string name="info_artist_sort">Artist sort</string>
<string name="info_sort_name">Sort name</string>
<string name="info_albums">Albums</string>
<string name="info_songs">Songs</string>
<string name="info_plays">Plays</string>
<string name="info_last_played">Last played</string>
<string name="info_added">Added</string>
<string name="info_created">Created</string>
<string name="info_updated">Updated</string>
<string name="info_owner">Owner</string>
<string name="info_shared">Shared</string>
<string name="info_length">Length</string>
<string name="info_downloaded_count">%1$d of %2$d</string>
<string name="disc_header">Disc %1$d</string>
<string name="disc_track">%1$s: Disc %2$d</string>
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.paging.PagingSource
import androidx.room3.Room
import androidx.room3.useReaderConnection
import androidx.room3.useWriterConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -19,11 +20,13 @@ import com.subtracks.data.model.Source
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SortNameMode
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -182,13 +185,14 @@ class LibraryDaoTest {
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1", playCount = 3, played = 100),
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", playCount = 4, played = 200),
song(sourceId, "s3", "Three", starred = null).copy(albumId = "al-2", playCount = 5, played = 400),
song(sourceId, "s2", "Two", starred = null)
.copy(albumId = "al-2", artistId = "ar-2", playCount = 4, played = 200),
song(sourceId, "s3", "Three", starred = null)
.copy(albumId = "al-2", artistId = "ar-2", playCount = 5, played = 400),
),
)
dao.recomputeAlbumPlayData(sourceId)
dao.recomputeArtistPlayData(sourceId)
dao.recomputeAggregates(sourceId)
assertEquals(3L, dao.albumOnce(sourceId, "al-1")!!.playCount)
assertEquals(100L, dao.albumOnce(sourceId, "al-1")!!.played)
@@ -329,10 +333,18 @@ class LibraryDaoTest {
runTest {
val orders =
listOf(
ALBUM_ORDER_BY_NAME to "index_albums_name",
ALBUM_ORDER_BY_NAME_REVERSED to "index_albums_name",
ALBUM_ORDER_BY_ARTIST to "index_albums_artist",
ALBUM_ORDER_BY_ARTIST_REVERSED to "index_albums_artist",
"${albumNameSortKey(SortNameMode.SortName)}, id" to "index_albums_name",
"${albumNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_albums_name",
"${albumNameSortKey(SortNameMode.Name)}, id" to "index_albums_name_plain",
"${albumNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_albums_name_plain",
"${albumArtistSortKey(SortNameMode.SortName)}, year, " +
"${albumNameSortKey(SortNameMode.SortName)}, id" to "index_albums_artist",
"${albumArtistSortKey(SortNameMode.SortName)} DESC, year DESC, " +
"${albumNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_albums_artist",
"${albumArtistSortKey(SortNameMode.Name)}, year, " +
"${albumNameSortKey(SortNameMode.Name)}, id" to "index_albums_artist_plain",
"${albumArtistSortKey(SortNameMode.Name)} DESC, year DESC, " +
"${albumNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_albums_artist_plain",
ALBUM_ORDER_BY_YEAR to "index_albums_year",
ALBUM_ORDER_BY_YEAR_REVERSED to "index_albums_year",
ALBUM_ORDER_BY_ADDED to "index_albums_added",
@@ -356,8 +368,10 @@ class LibraryDaoTest {
runTest {
val orders =
listOf(
ARTIST_ORDER_BY_NAME to "index_artists_name",
ARTIST_ORDER_BY_NAME_REVERSED to "index_artists_name",
"${artistNameSortKey(SortNameMode.SortName)}, id" to "index_artists_name",
"${artistNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_artists_name",
"${artistNameSortKey(SortNameMode.Name)}, id" to "index_artists_name_plain",
"${artistNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_artists_name_plain",
ARTIST_ORDER_BY_ALBUM_COUNT to "index_artists_albumCount",
ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED to "index_artists_albumCount",
ARTIST_ORDER_BY_STARRED to "index_artists_starred",
@@ -514,6 +528,361 @@ class LibraryDaoTest {
assertEquals(listOf("al-1"), dao.albumsForArtistDownloaded(sourceId, "ar-guest").first().map { it.id })
}
@Test
fun albumCreditsAreSeparatedFromTrackAppearances() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "Own", year = null, starred = null),
album(sourceId, "al-2", "Other", year = null, starred = null).copy(artistId = "ar-2"),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One", starred = null),
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", artistId = "ar-1"),
),
)
assertEquals(listOf("al-1"), dao.albumCreditAlbumIds(sourceId, "ar-1").first())
assertEquals(listOf("al-2"), dao.albumCreditAlbumIds(sourceId, "ar-2").first())
assertEquals(
listOf("al-1", "al-2"),
dao
.albumsForArtist(sourceId, "ar-1")
.first()
.map { it.id }
.sorted(),
)
}
@Test
fun artistArtIsMaterializedFromAlbumCreditsThenTrackCredits() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-own", "Own", albumCount = 1).copy(coverArt = "own-art"),
artist(sourceId, "ar-album", "Album", albumCount = 2),
artist(sourceId, "ar-track", "Track", albumCount = 1),
artist(sourceId, "ar-album-ref", "Album Ref", albumCount = 1),
artist(sourceId, "ar-track-ref", "Track Ref", albumCount = 1),
artist(sourceId, "ar-null-art", "Null Art", albumCount = 2),
artist(sourceId, "ar-none", "None", albumCount = 0),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "own", "Own", year = 2020, starred = null).copy(coverArt = "own-album"),
album(sourceId, "album-new", "New", year = 2020, starred = null)
.copy(artistId = "ar-album", coverArt = "new-art"),
album(sourceId, "album-old", "Old", year = 2000, starred = null)
.copy(artistId = "ar-album", coverArt = "old-art"),
album(sourceId, "track", "Track", year = 2021, starred = null)
.copy(artistId = "ar-other", coverArt = "track-art"),
album(sourceId, "album-ref", "Album Ref", year = 2021, starred = null)
.copy(artistId = "ar-other", coverArt = "album-ref-art"),
album(sourceId, "track-ref", "Track Ref", year = 2019, starred = null)
.copy(artistId = "ar-other", coverArt = "track-ref-art"),
album(sourceId, "null-new", "Null New", year = 2022, starred = null)
.copy(artistId = "ar-null-art", coverArt = null),
album(sourceId, "null-old", "Null Old", year = 1990, starred = null)
.copy(artistId = "ar-null-art", coverArt = "later-art"),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s-own", "Own", starred = null).copy(albumId = "own", artistId = "ar-own"),
song(sourceId, "s-album", "Album", starred = null).copy(albumId = "album-new", artistId = "ar-album"),
song(sourceId, "s-track", "Track", starred = null).copy(albumId = "track", artistId = "ar-track"),
song(sourceId, "s-album-ref", "Album Ref", starred = null)
.copy(albumId = "album-ref", artistId = "ar-other"),
song(sourceId, "s-track-ref", "Track Ref", starred = null)
.copy(albumId = "track-ref", artistId = "ar-other"),
song(sourceId, "s-null-new", "Null New", starred = null)
.copy(albumId = "null-new", artistId = "ar-null-art"),
song(sourceId, "s-null-old", "Null Old", starred = null)
.copy(albumId = "null-old", artistId = "ar-null-art"),
),
)
db.useWriterConnection { connection ->
connection.usePrepared(
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
"VALUES (?, 'album-ref', 0, 'ar-album-ref', 'Album Ref')",
) { statement ->
statement.bindLong(1, sourceId)
statement.step()
}
connection.usePrepared(
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
"VALUES (?, 's-track-ref', 0, 'ar-track-ref', 'Track Ref')",
) { statement ->
statement.bindLong(1, sourceId)
statement.step()
}
}
dao.recomputeArtistArt(sourceId)
val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id }
assertEquals("own-art", artists.getValue("ar-own").coverArt)
assertNull(artists.getValue("ar-album").coverArt)
assertNull(artists.getValue("ar-track").coverArt)
assertNull(artists.getValue("ar-album-ref").coverArt)
assertNull(artists.getValue("ar-track-ref").coverArt)
assertNull(artists.getValue("ar-null-art").coverArt)
assertNull(artists.getValue("ar-none").coverArt)
assertEquals(listOf("own-album"), artists.getValue("ar-own").coverArts)
assertEquals(listOf("new-art", "old-art"), artists.getValue("ar-album").coverArts)
assertEquals(listOf("track-art"), artists.getValue("ar-track").coverArts)
assertEquals(listOf("album-ref-art"), artists.getValue("ar-album-ref").coverArts)
assertEquals(listOf("track-ref-art"), artists.getValue("ar-track-ref").coverArts)
assertEquals(listOf("later-art"), artists.getValue("ar-null-art").coverArts)
assertEquals(emptyList<String>(), artists.getValue("ar-none").coverArts)
assertEquals(listOf("new-art", "old-art"), dao.artistOnce(sourceId, "ar-album")!!.coverArts)
assertEquals(
listOf("album-ref-art"),
dao
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
.page()
.first { it.id == "ar-album-ref" }
.coverArts,
)
}
@Test
fun coverArtFallbackFindsEveryCreditPath() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "Album", year = 2020, starred = null)
.copy(artistId = "ar-other", coverArt = "track-art"),
album(sourceId, "al-2", "Featured", year = 2021, starred = null)
.copy(artistId = "ar-other", coverArt = "featured-art"),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s-1", "Song", starred = null)
.copy(albumId = "al-1", artistId = "ar-missing"),
song(sourceId, "s-2", "Featured", starred = null)
.copy(albumId = "al-2", artistId = "ar-other"),
),
)
db.useWriterConnection { connection ->
connection.usePrepared(
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
"VALUES (?, 'al-2', 0, 'ar-featured', 'Featured')",
) { statement ->
statement.bindLong(1, sourceId)
statement.step()
}
connection.usePrepared(
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
"VALUES (?, 's-2', 0, 'ar-guest', 'Guest')",
) { statement ->
statement.bindLong(1, sourceId)
statement.step()
}
}
assertNull(dao.artistOnce(sourceId, "ar-missing"))
assertNull(dao.artistOnce(sourceId, "ar-featured"))
assertNull(dao.artistOnce(sourceId, "ar-guest"))
assertEquals(listOf("track-art"), dao.artistCoverArts(sourceId, "ar-missing").first())
assertEquals(listOf("featured-art", "track-art"), dao.artistCoverArts(sourceId, "ar-other").first())
assertEquals(listOf("featured-art"), dao.artistCoverArts(sourceId, "ar-featured").first())
assertEquals(listOf("featured-art"), dao.artistCoverArts(sourceId, "ar-guest").first())
}
@Test
fun artistCoverArtsRanksDeduplicatesCapsAndStaysInSource() =
runTest {
val sourceId = source()
val otherSourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(listOf(artist(sourceId, "ar-rank", "Rank", albumCount = 0)))
dao.upsertAlbums(
listOf(
album(sourceId, "a-new", "New", year = 2020, starred = null)
.copy(artistId = "ar-rank", coverArt = "shared"),
album(sourceId, "a-mid", "Mid", year = 2015, starred = null)
.copy(artistId = "ar-rank", coverArt = "other"),
album(sourceId, "a-old", "Old", year = 2010, starred = null)
.copy(artistId = "ar-rank", coverArt = "shared"),
album(sourceId, "a-4", "Four", year = 2012, starred = null)
.copy(artistId = "ar-rank", coverArt = "c4"),
album(sourceId, "a-5", "Five", year = 2011, starred = null)
.copy(artistId = "ar-rank", coverArt = "c5"),
album(sourceId, "a-6", "Six", year = 2009, starred = null)
.copy(artistId = "ar-rank", coverArt = "c6"),
album(otherSourceId, "a-leak", "Leak", year = 2025, starred = null)
.copy(artistId = "ar-rank", coverArt = "leak"),
),
)
val expected = listOf("shared", "other", "c4", "c5")
assertEquals(expected, dao.artistCoverArts(sourceId, "ar-rank").first())
dao.recomputeArtistArt(sourceId)
assertEquals(expected, dao.artistOnce(sourceId, "ar-rank")!!.coverArts)
}
@Test
fun downloadedArtistArtUsesOnlyDownloadedAlbums() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 3)))
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "One", year = 2020, starred = null)
.copy(artistId = "ar-1", coverArt = "art-1"),
album(sourceId, "al-2", "Two", year = 2021, starred = null)
.copy(artistId = "ar-1", coverArt = "art-2"),
album(sourceId, "al-3", "Three", year = 2022, starred = null)
.copy(artistId = "ar-1", coverArt = "art-3"),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1", artistId = "ar-1"),
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", artistId = "ar-1"),
song(sourceId, "s3", "Three", starred = null).copy(albumId = "al-3", artistId = "ar-1"),
),
)
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Queued))
dao.recomputeArtistArt(sourceId)
val artist = dao.artistOnce(sourceId, "ar-1")!!
assertEquals(listOf("art-3", "art-2", "art-1"), artist.coverArts)
assertEquals(listOf("art-1"), artist.downloadedCoverArts)
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed))
dao.recomputeDownloadedArtistArt(sourceId, listOf("ar-1"))
assertEquals(listOf("art-2", "art-1"), dao.artistOnce(sourceId, "ar-1")!!.downloadedCoverArts)
}
@Test
fun artistNameUpdatesKeepTheSearchIndexValid() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 0)))
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Renamed", albumCount = 0)))
val hits =
db.useReaderConnection { connection ->
connection.usePrepared("SELECT COUNT(*) FROM artist_search WHERE artist_search MATCH 'Renamed'") {
it.step()
it.getLong(0)
}
}
assertEquals(1L, hits)
}
@Test
fun artistArtDeduplicatesCoversBeforeApplyingTheFourCoverBudget() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 6)))
dao.upsertAlbums(
listOf("A", "A", "O", "B", "C", "D").mapIndexed { index, cover ->
album(sourceId, "al-$index", "Album $index", year = 2021L - index, starred = null)
.copy(artistId = "ar-1", coverArt = cover)
},
)
dao.recomputeArtistArt(sourceId)
assertEquals(
listOf("A", "O", "B", "C"),
dao.artistOnce(sourceId, "ar-1")!!.coverArts,
)
}
@Test
fun artistArtMaterializationUsesTheCreditIndexes() =
runTest {
val plan = plan(ARTIST_ART_UPDATE_SQL)
assertTrue(plan, plan.contains("index_albums_sourceId_artistId"))
assertTrue(plan, plan.contains("index_album_artists_sourceId_artistId"))
assertTrue(plan, plan.contains("index_songs_sourceId_artistId"))
assertTrue(plan, plan.contains("index_song_artists_sourceId_artistId"))
}
@Test
fun creditedSongArtistsIncludeTheAlbumArtistAndTrackCredits() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-album", "Album Artist", albumCount = 1),
artist(sourceId, "ar-track", "Track Artist", albumCount = 0),
artist(sourceId, "ar-guest", "Guest", albumCount = 0),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "Album", year = null, starred = null)
.copy(artistId = "ar-album"),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s1", "Song", starred = null)
.copy(albumId = "al-1", artistId = "ar-track"),
),
)
db.useWriterConnection { connection ->
connection.usePrepared(
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
"VALUES (?, 's1', 0, 'ar-guest', 'Guest')",
) { statement ->
statement.bindLong(1, sourceId)
statement.step()
}
}
assertEquals(
setOf("ar-album", "ar-track", "ar-guest"),
dao.creditedArtistIdsForSongs(sourceId, listOf("s1")).toSet(),
)
}
@Test
fun albumCreditIdsFindCreditsThroughArtistIndexes() =
runTest {
val plan = plan(ALBUM_CREDIT_ALBUM_IDS)
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
)
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
)
assertFalse(plan, plan.contains("SCAN albums"))
assertFalse(plan, plan.contains("SCAN songs"))
}
@Test
fun downloadedArtistsIncludeOneCreditedOnlyOnADownloadedTrack() =
runTest {
@@ -607,7 +976,7 @@ class LibraryDaoTest {
}
@Test
fun downloadedFilterShowsDownloadedAlbumCountsButSortsByTheSyncedCount() =
fun downloadedFilterSortsByTheDownloadedAlbumCountItShows() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
@@ -635,11 +1004,11 @@ class LibraryDaoTest {
db.downloadDao().upsert(SongDownload(sourceId, it, DownloadStatus.Completed))
}
// ar-1 has two downloaded albums, ar-2 one. The list still orders by the synced count
// (so the album-count index stays usable) but reports the downloaded count.
// ar-1 has two downloaded albums, ar-2 one, so the downloaded order is ar-1 first even
// though the synced counts would put ar-2 first.
val filtered = dao.artistsByAlbumCount(sourceId, 0, "", downloadedFilter = 1).page()
assertEquals(listOf("ar-2", "ar-1"), filtered.map { it.id })
assertEquals(listOf(1L, 2L), filtered.map { it.albumCount })
assertEquals(listOf("ar-1", "ar-2"), filtered.map { it.id })
assertEquals(listOf(2L, 1L), filtered.map { it.albumCount })
}
@Test
@@ -647,7 +1016,12 @@ class LibraryDaoTest {
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertPlaylists(listOf(playlist(sourceId, "pl-1", "List", created = 0, changed = 0).copy(songCount = 5)))
dao.upsertPlaylists(
listOf(
playlist(sourceId, "pl-1", "List", created = 0, changed = 0)
.copy(songCount = 5, owner = "austin", shared = true),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One", starred = null),
@@ -665,6 +1039,8 @@ class LibraryDaoTest {
val filtered = dao.playlistsByName(sourceId, "", downloadedFilter = 1).page()
assertEquals(1, filtered.size)
assertEquals(1L, filtered.first().songCount)
assertEquals("austin", filtered.first().owner)
assertEquals(true, filtered.first().shared)
assertEquals(
5L,
@@ -778,7 +1154,7 @@ class LibraryDaoTest {
val plan =
plan(
"SELECT * ${ALBUMS_FILTER.replace(":search", "'abc'")} " +
"ORDER BY $ALBUM_ORDER_BY_NAME LIMIT 20 OFFSET 0",
"ORDER BY ${albumNameSortKey(SortNameMode.SortName)}, id LIMIT 20 OFFSET 0",
)
assertTrue(plan, plan.contains("album_search"))
@@ -855,6 +1231,25 @@ class LibraryDaoTest {
assertTrue(dao.searchSongs(sourceId, "ga", 50).first().isEmpty())
}
@Test
fun theArtistPageAlbumQueryFindsArtistCreditsThroughArtistIndexes() =
runTest {
val plan = plan(ALBUMS_FOR_ARTIST)
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
)
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
)
assertFalse(plan, plan.contains("SCAN albums"))
assertFalse(plan, plan.contains("SCAN songs"))
}
private suspend fun plan(sql: String): String =
db.useReaderConnection { connection ->
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->
@@ -978,6 +1373,149 @@ class LibraryDaoTest {
awaitUntil("the default list observes album writes") { defaultInvalidated.get() }
}
@Test
fun sortNamesDriveArtistAndAlbumOrder() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-1", "あいみょん", albumCount = 1).copy(sortName = "Zzz"),
artist(sourceId, "ar-2", "宇多田ヒカル", albumCount = 1).copy(sortName = "Aaa"),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "Zzz", year = null, starred = null)
.copy(sortName = "Aaa", albumArtistSort = "Zzz"),
album(sourceId, "al-2", "Aaa", year = null, starred = null)
.copy(sortName = "Zzz", albumArtistSort = "Aaa"),
),
)
assertEquals(
listOf("宇多田ヒカル", "あいみょん"),
dao.artistsByName(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("あいみょん", "宇多田ヒカル"),
dao.artistsByNameReversed(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Zzz", "Aaa"),
dao.albumsByName(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Aaa", "Zzz"),
dao.albumsByNameReversed(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Aaa", "Zzz"),
dao.albumsByArtist(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Zzz", "Aaa"),
dao.albumsByArtistReversed(sourceId, 0, "").page().map { it.name },
)
}
@Test
fun theSortNamePreferenceChoosesTheOrderKey() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-1", "あいみょん", albumCount = 1).copy(sortName = "Zzz"),
artist(sourceId, "ar-2", "宇多田ヒカル", albumCount = 1).copy(sortName = "Aaa"),
artist(sourceId, "ar-3", "Beatles", albumCount = 1).copy(sortName = "Beatles, The"),
artist(sourceId, "ar-4", "Zebra", albumCount = 1).copy(sortName = "Aaa"),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "Zzz", year = null, starred = null)
.copy(sortName = "Aaa", albumArtistSort = "Zzz", albumArtist = "Beatles"),
album(sourceId, "al-2", "Aaa", year = null, starred = null)
.copy(sortName = "Zzz", albumArtistSort = "Aaa", albumArtist = "Aaa"),
album(sourceId, "al-3", "Beatles", year = null, starred = null)
.copy(sortName = "Beatles, The", albumArtistSort = "Beatles, The", albumArtist = "Zebra"),
),
)
assertEquals(
listOf("宇多田ヒカル", "Zebra", "Beatles", "あいみょん"),
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.SortName.name).page().map { it.name },
)
assertEquals(
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
)
assertEquals(
listOf("宇多田ヒカル", "あいみょん", "Zebra", "Beatles"),
dao.artistsByNameReversed(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
)
assertEquals(
listOf("宇多田ヒカル", "Beatles", "Zebra", "あいみょん"),
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.NonLatin.name).page().map { it.name },
)
assertEquals(
listOf("Aaa", "Beatles", "Zzz"),
dao.albumsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
)
assertEquals(
listOf("Aaa", "Zzz", "Beatles"),
dao.albumsByArtist(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
)
assertEquals(
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
dao
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false, SortNameMode.Name))
.page()
.map { it.name },
)
assertEquals(
listOf("Aaa", "Beatles", "Zzz"),
dao
.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false, SortNameMode.Name))
.page()
.map { it.name },
)
}
@Test
fun sortNamesAreSearchableInArtistsAlbumsAndSongs() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-1", "宇多田ヒカル", albumCount = 1)
.copy(sortName = "Utada Hikaru"),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "First Love", year = null, starred = null)
.copy(sortName = "First Love", albumArtistSort = "Utada Hikaru"),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s1", "Automatic", starred = null)
.copy(sortName = "Automatic", artist = "宇多田ヒカル", artistSort = "Utada Hikaru"),
),
)
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "utada").page().map { it.name })
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "ut").page().map { it.name })
assertEquals(listOf("宇多田ヒカル"), dao.searchArtists(sourceId, "utada", 10).first().map { it.name })
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "first").page().map { it.name })
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "fi").page().map { it.name })
assertEquals(listOf("First Love"), dao.searchAlbums(sourceId, "first", 10).first().map { it.name })
assertEquals(listOf("Automatic"), dao.searchSongs(sourceId, "utada", 10).first().map { it.song.title })
}
private suspend fun source(): Long =
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
@@ -7,6 +7,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@@ -54,17 +55,19 @@ class MigrationsTest {
applySchema(migrated, versions.first())
MIGRATIONS.sortedBy { it.startVersion }.forEach { it.migrate(migrated) }
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "name").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtist").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "name").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "sortName").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtistSort").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "sortName").collation)
assertEquals(1, indexColumn(migrated, "index_albums_year", "year").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "name").collation)
assertEquals(0, indexColumn(migrated, "index_albums_year", "name").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "sortName").collation)
assertEquals(0, indexColumn(migrated, "index_albums_year", "sortName").descending)
assertEquals(1, indexColumn(migrated, "index_albums_added", "created").descending)
assertEquals(1, indexColumn(migrated, "index_albums_starred", "starred").descending)
assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending)
assertEquals(1, indexColumn(migrated, "index_albums_recent", "played").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "name").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_songs_starred", "sortName").collation)
assertEquals(1, indexColumn(migrated, "index_songs_starred", "starred").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "sortName").collation)
assertEquals(1, indexColumn(migrated, "index_artists_albumCount", "albumCount").descending)
assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending)
assertEquals(1, indexColumn(migrated, "index_artists_frequent", "playCount").descending)
@@ -134,6 +137,128 @@ class MigrationsTest {
}
}
@Test
fun theArtistBioTableStoresTheLatestBiography() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 34)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
MIGRATION_34_35.migrate(migrated)
migrated.execSQL("INSERT INTO artist_bios (sourceId, artistId, biography) VALUES (1, 'ar1', 'Old')")
migrated.execSQL("UPDATE artist_bios SET biography = 'New' WHERE sourceId = 1 AND artistId = 'ar1'")
assertEquals("New", text(migrated, "SELECT biography FROM artist_bios WHERE artistId = 'ar1'"))
}
}
@Test
fun theArtistArtColumnsDefaultToEmptyAndAcceptValues() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 35)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
"VALUES (1, 'ar1', 'Artist', 0, NULL)",
)
MIGRATION_35_36.migrate(migrated)
assertEquals(1L, long(migrated, "SELECT artistImageUrl IS NULL FROM artists WHERE id = 'ar1'"))
assertEquals("", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
assertEquals("", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
migrated.execSQL(
"UPDATE artists SET artistImageUrl = 'http://img', coverArts = 'a\u001fb', " +
"downloadedCoverArts = 'c' WHERE id = 'ar1'",
)
assertEquals("http://img", text(migrated, "SELECT artistImageUrl FROM artists WHERE id = 'ar1'"))
assertEquals("a\u001fb", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
assertEquals("c", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
}
}
@Test
fun theArtistArtMigrationBackfillsExistingLibraries() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 35)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
"VALUES (1, 'ar1', 'Artist', 3, NULL)",
)
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, " +
"year, starred, songCount) VALUES " +
"(1, 'al1', 'ar1', 'Shared', 'Artist', 0, 'shared', NULL, 2020, NULL, 1), " +
"(1, 'al2', 'ar1', 'Shared Newer', 'Artist', 0, 'shared', NULL, 2021, NULL, 1), " +
"(1, 'al3', 'ar1', 'Other', 'Artist', 0, 'other', NULL, 2019, NULL, 1)",
)
migrated.execSQL(
"INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES " +
"(1, 's1', 'al1', 'ar1', 'One'), (1, 's2', 'al2', 'ar1', 'Two'), " +
"(1, 's3', 'al3', 'ar1', 'Three')",
)
migrated.execSQL(
"INSERT INTO song_downloads (sourceId, songId, status, bytes, total) VALUES " +
"(1, 's1', 'Completed', 1, 1), (1, 's2', 'Completed', 1, 1)",
)
MIGRATION_35_36.migrate(migrated)
assertEquals("shared\u001fother", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
assertEquals("shared", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
}
}
@Test
fun theArtistSearchTriggersIgnoreArtOnlyUpdates() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 35)
MIGRATION_35_36.migrate(migrated)
assertTrue(artistSearchTriggersOptimized(migrated))
assertTrue(
triggerSql(migrated, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
.contains("UPDATE OF `name`, `sortName`"),
)
assertTrue(
triggerSql(migrated, "room_fts_content_sync_artist_search_AFTER_UPDATE")
.contains("UPDATE OF `name`, `sortName`"),
)
}
}
@Test
fun theFreshDatabaseCallbackOptimizesTheArtistSearchTriggers() =
runTest {
driver.open(":memory:").use { connection ->
applySchema(connection, 36)
connection.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
connection.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
"VALUES (1, 'ar1', 'Artist', 0, NULL)",
)
ArtistSearchTriggerOptimization.onOpen(connection)
assertTrue(artistSearchTriggersOptimized(connection))
val before = long(connection, "SELECT total_changes()")
connection.execSQL("UPDATE artists SET coverArts = 'a' WHERE id = 'ar1'")
assertEquals(before + 1, long(connection, "SELECT total_changes()"))
}
}
@Test
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
runTest {
@@ -153,6 +278,211 @@ class MigrationsTest {
}
}
@Test
fun theArtistSongCountBackfillCountsLegacyCredits() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 29)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'ar1', 'Album Artist', 1)")
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'ar2', 'Guest', 0)")
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
"VALUES (1, 'al1', 'ar1', 'Album', 'Album Artist', 0, 1)",
)
migrated.execSQL("INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES (1, 's1', 'al1', 'ar1', 'One')")
migrated.execSQL("INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES (1, 's2', NULL, 'ar2', 'Two')")
MIGRATION_29_30.migrate(migrated)
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'ar1'"))
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'ar2'"))
}
}
@Test
fun theSortNameBackfillUsesDisplayNamesAndArtistSortNames() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 30)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount) " +
"VALUES (1, 'ar1', 'Utada Hikaru', 1)",
)
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
"VALUES (1, 'al1', 'ar1', 'First Love', 'Utada Hikaru', 0, 1)",
)
migrated.execSQL(
"INSERT INTO songs (sourceId, id, albumId, artistId, title) " +
"VALUES (1, 's1', 'al1', 'ar1', 'Automatic')",
)
MIGRATION_30_31.migrate(migrated)
assertEquals("Utada Hikaru", text(migrated, "SELECT sortName FROM artists WHERE id = 'ar1'"))
assertEquals("First Love", text(migrated, "SELECT sortName FROM albums WHERE id = 'al1'"))
assertEquals("Utada Hikaru", text(migrated, "SELECT albumArtistSort FROM albums WHERE id = 'al1'"))
assertEquals("Automatic", text(migrated, "SELECT sortName FROM songs WHERE id = 's1'"))
assertEquals("Utada Hikaru", text(migrated, "SELECT artistSort FROM songs WHERE id = 's1'"))
assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM artist_search WHERE artist_search MATCH 'utada'"))
assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM album_search WHERE album_search MATCH 'first'"))
assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM song_search WHERE song_search MATCH 'utada'"))
}
}
@Test
fun theArtistCreditedFlagsHideRowsWithoutCredits() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 31)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'a1', 'Alpha', 1)")
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'combo', 'Alpha & Beta', 1)")
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
"VALUES (1, 'al1', 'a1', 'Album', 'Alpha', 0, 0)",
)
migrated.execSQL(
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
"VALUES (1, 'al1', 0, 'a1', 'Alpha')",
)
migrated.execSQL(
"INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES (1, 's1', 'al1', 'a1', 'Song')",
)
migrated.execSQL(
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
"VALUES (1, 's1', 0, 'a1', 'Alpha')",
)
MIGRATION_31_32.migrate(migrated)
assertEquals(1L, long(migrated, "SELECT credited FROM artists WHERE id = 'a1'"))
assertEquals(0L, long(migrated, "SELECT credited FROM artists WHERE id = 'combo'"))
}
}
@Test
fun theCreditedBackfillKeepsArtistsVisibleWhileRelationsAreIncomplete() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 31)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'a1', 'Alpha', 1)")
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'combo', 'Alpha & Beta', 1)")
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
"VALUES (1, 'al1', 'a1', 'Album', 'Alpha', 0, 0)",
)
migrated.execSQL(
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
"VALUES (1, 'al1', 0, 'a1', 'Alpha')",
)
MIGRATION_31_32.migrate(migrated)
assertEquals(1L, long(migrated, "SELECT credited FROM artists WHERE id = 'a1'"))
assertEquals(1L, long(migrated, "SELECT credited FROM artists WHERE id = 'combo'"))
}
}
@Test
fun theCreditTotalsBackfillUsesEveryCreditedSong() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, 33)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (2, 't', 'http://y', 1, 0)",
)
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (3, 'u', 'http://z', 1, 0)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
"VALUES (1, 'a1', 'Album Artist', 1, 0, 0, NULL)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
"VALUES (1, 'a2', 'Guest', 0, 0, 0, NULL)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
"VALUES (2, 'b1', 'Unsynced', 7, 6, 5, 100)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
"VALUES (3, 'c1', 'Track Only', 7, 6, 5, 100)",
)
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
"VALUES (1, 'al1', 'a1', 'Album', 'Album Artist', 0, 1)",
)
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
"VALUES (1, 'al2', 'a2', 'Empty Album', 'Guest', 0, 0)",
)
migrated.execSQL(
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
"VALUES (1, 'al1', 0, 'a1', 'Album Artist')",
)
migrated.execSQL(
"INSERT INTO songs (sourceId, id, albumId, artistId, title, playCount, played) " +
"VALUES (1, 's1', 'al1', 'a1', 'One', 4, 200)",
)
migrated.execSQL(
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
"VALUES (1, 's1', 0, 'a1', 'Album Artist')",
)
migrated.execSQL(
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
"VALUES (1, 's1', 1, 'a2', 'Guest')",
)
migrated.execSQL(
"INSERT INTO songs (sourceId, id, albumId, artistId, title, playCount, played) " +
"VALUES (3, 's3', NULL, 'c1', 'Three', 2, 50)",
)
migrated.execSQL(
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
"VALUES (3, 's3', 0, 'c1', 'Track Only')",
)
migrated.execSQL(
"INSERT INTO songs (sourceId, id, albumId, artistId, title, playCount, played) " +
"VALUES (3, 's4', 'missing', 'c1', 'Four', 3, 60)",
)
MIGRATION_33_34.migrate(migrated)
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'a1'"))
assertEquals(1L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'a1'"))
assertEquals(4L, long(migrated, "SELECT playCount FROM artists WHERE id = 'a1'"))
assertEquals(200L, long(migrated, "SELECT played FROM artists WHERE id = 'a1'"))
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'a2'"))
assertEquals(2L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'a2'"))
assertEquals(4L, long(migrated, "SELECT playCount FROM artists WHERE id = 'a2'"))
assertEquals(200L, long(migrated, "SELECT played FROM artists WHERE id = 'a2'"))
assertEquals(6L, long(migrated, "SELECT songCount FROM artists WHERE id = 'b1'"))
assertEquals(7L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'b1'"))
assertEquals(5L, long(migrated, "SELECT playCount FROM artists WHERE id = 'b1'"))
assertEquals(100L, long(migrated, "SELECT played FROM artists WHERE id = 'b1'"))
assertEquals(2L, long(migrated, "SELECT songCount FROM artists WHERE id = 'c1'"))
assertEquals(0L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'c1'"))
assertEquals(5L, long(migrated, "SELECT playCount FROM artists WHERE id = 'c1'"))
assertEquals(60L, long(migrated, "SELECT played FROM artists WHERE id = 'c1'"))
}
}
private fun text(
connection: SQLiteConnection,
sql: String,
@@ -171,6 +501,16 @@ class MigrationsTest {
statement.getLong(0)
}
private fun triggerSql(
connection: SQLiteConnection,
trigger: String,
): String =
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?").use { statement ->
statement.bindText(1, trigger)
check(statement.step()) { "No trigger $trigger" }
statement.getText(0)
}
private fun indexColumn(
connection: SQLiteConnection,
index: String,
@@ -204,6 +544,10 @@ class MigrationsTest {
for (i in 0 until indices.length()) {
connection.execSQL(indices.getJSONObject(i).getString("createSql").replace("\${TABLE_NAME}", name))
}
val triggers = entity.optJSONArray("contentSyncTriggers") ?: continue
for (i in 0 until triggers.length()) {
connection.execSQL(triggers.getString(i))
}
}
}
@@ -3,6 +3,8 @@ package com.subtracks.data.db
import android.content.Context
import androidx.paging.PagingSource
import androidx.room3.Room
import androidx.room3.immediateTransaction
import androidx.room3.useWriterConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -37,7 +39,12 @@ class QueryTimingMeasurementTest {
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
db = Room.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java).setDriver(driver).build()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(driver)
.addCallback(ArtistSearchTriggerOptimization)
.build()
}
@After
@@ -61,6 +68,27 @@ class QueryTimingMeasurementTest {
val secondSyncMs = wall { SyncService(db, source).sync() }
val secondSyncExecutions = executions.toList()
db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.usePrepared(
"INSERT INTO song_downloads (sourceId, songId, status, downloadedAt) VALUES (?, ?, ?, ?)",
) { statement ->
(0 until songCount step 10).forEach { i ->
statement.bindLong(1, 1L)
statement.bindText(2, "s$i")
statement.bindText(3, "Completed")
statement.bindLong(4, i.toLong())
statement.step()
statement.reset()
statement.clearBindings()
}
}
}
}
db.useWriterConnection { connection ->
connection.usePrepared("ANALYZE") { it.step() }
}
executions.clear()
measureReads()
val readExecutions = executions.toList()
@@ -91,6 +119,15 @@ class QueryTimingMeasurementTest {
timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() }
timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() }
timed("artists first page (frequent)") { dao.artistsByFrequent(1, 0, "").firstPage() }
timed("artist albums") { dao.albumsForArtist(1, "ar0").first() }
timed("artist name") { dao.artistName(1, "ar0").first() }
timed("artist songs") { db.queueDao().artistSongs(1, "ar0", 0, 100) }
timed("artist length") { db.queueDao().artistLength(1, "ar0") }
timed("artist song ids") { db.queueDao().artistSongIds(1, "ar0") }
timed("downloaded artists") { dao.downloadedArtists(1).firstPage() }
timed("artists downloaded filter") { dao.artistsByName(1, 0, "", downloadedFilter = 1).firstPage() }
timed("artist statuses") { db.downloadDao().artistStatuses(1).first() }
timed("artist status") { db.downloadDao().artistStatus(1, "ar0").first() }
timed("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() }
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() }
@@ -102,7 +139,7 @@ class QueryTimingMeasurementTest {
timed("home recently starred") { dao.recentlyStarredSongs(1, 20).first() }
timed("home genres") { dao.genresByMostPlayed(1).first() }
timed("home decades") { dao.decades(1).first() }
timed("recompute play data") { dao.recomputePlayData(1) }
timed("recompute aggregates") { dao.recomputeAggregates(1) }
}
private suspend fun <T : Any> PagingSource<Int, T>.firstPage(): List<T> =
@@ -0,0 +1,25 @@
package com.subtracks.data.model
import org.junit.Assert.assertEquals
import org.junit.Test
class ArtistRefsCodecTest {
@Test
fun roundTripsNamesWithTheOldSeparatorsAndColons() {
val refs =
listOf(
ArtistRef("ar1", "Name\u001Ewith\u001Fseparators"),
ArtistRef("ar:2", "1:Colon Artist"),
ArtistRef("", ""),
)
assertEquals(refs, ArtistRefsCodec.decode(ArtistRefsCodec.encode(refs)))
}
@Test
fun decodesEmptyAndMalformedValuesAsEmpty() {
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode(""))
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode("not a ref"))
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode("9:short4:name"))
}
}
@@ -51,6 +51,22 @@ class UserPreferencesTest {
file.delete()
}
@Test
fun theSortNameModeRoundTrips() =
runTest {
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val prefs = UserPreferences(store)
assertEquals(SortNameMode.Name, prefs.sortNameMode().first())
prefs.setSortNameMode(SortNameMode.NonLatin)
assertEquals(SortNameMode.NonLatin, prefs.sortNameMode().first())
file.delete()
}
@Test
fun theSelectedLibraryTabRoundTrips() =
runTest {
@@ -155,6 +171,24 @@ class UserPreferencesTest {
file.delete()
}
@Test
fun keepScreenOnDefaultsOffAndRoundTrips() =
runTest {
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val prefs = UserPreferences(store)
assertEquals(false, prefs.keepScreenOn().first())
prefs.setKeepScreenOn(true)
assertEquals(true, prefs.keepScreenOn().first())
prefs.setKeepScreenOn(false)
assertEquals(false, prefs.keepScreenOn().first())
file.delete()
}
@Test
fun lastSeedRoundTripsWithAndWithoutASecondary() =
runTest {
@@ -188,6 +222,37 @@ class UserPreferencesTest {
prefs.setVerboseLogging(true)
assertEquals(true, prefs.verboseLogging().first())
file.delete()
}
@Test
fun replayGainDefaultsToAutoModeAndRoundTrips() =
runTest {
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val prefs = UserPreferences(store)
assertEquals(ReplayGainMode.Auto, prefs.replayGainMode().first())
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainMode(ReplayGainMode.Album)
prefs.setReplayGainPreampDb(3.5f)
assertEquals(ReplayGainMode.Album, prefs.replayGainMode().first())
assertEquals(3.5f, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainMode(ReplayGainMode.Auto)
assertEquals(ReplayGainMode.Auto, prefs.replayGainMode().first())
prefs.setReplayGainPreampDb(99f)
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainPreampDb(-99f)
assertEquals(MIN_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainPreampDb(Float.NaN)
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
file.delete()
}
}
@@ -26,6 +26,7 @@ import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.CoroutineScope
@@ -36,6 +37,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -49,6 +54,7 @@ import org.junit.runner.RunWith
import java.io.File
import java.io.IOException
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
class DownloadRepositoryTest {
@@ -65,11 +71,12 @@ class DownloadRepositoryTest {
private val requestedArt = CopyOnWriteArrayList<String>()
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
private var failArtworkFetch = false
private val failingArtUrls = CopyOnWriteArrayList<String>()
private val fetcher =
ArtworkFetcher { url ->
requestedArt += url
artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty()
if (failArtworkFetch) throw IOException("boom")
if (failArtworkFetch || url in failingArtUrls) throw IOException("boom")
byteArrayOf(1, 2, 3)
}
@@ -121,6 +128,49 @@ class DownloadRepositoryTest {
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
}
@Test
fun aDownloadAlsoFetchesTheArtistsBiography() =
runTest {
val server = MockWebServer()
server.start()
try {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse =
when (request.requestUrl!!.encodedPath) {
"/rest/ping.view" -> {
MockResponse().setBody("<subsonic-response status=\"ok\"/>")
}
"/rest/getArtistInfo2.view" -> {
MockResponse().setBody(
"<subsonic-response status=\"ok\"><artistInfo2>" +
"<biography>Downloaded bio</biography>" +
"</artistInfo2></subsonic-response>",
)
}
else -> {
MockResponse().setResponseCode(404)
}
}
}
seedLibrary(server.url("/").toString())
runBlocking { repository.download(1, "s1") }
await { artistBio(1, "ar1") != null }
assertEquals("Downloaded bio", artistBio(1, "ar1")?.biography)
var request = server.takeRequest(5, TimeUnit.SECONDS)!!
while (request.requestUrl!!.encodedPath != "/rest/getArtistInfo2.view") {
request = server.takeRequest(5, TimeUnit.SECONDS)!!
}
assertEquals("ar1", request.requestUrl!!.queryParameter("id"))
} finally {
server.shutdown()
}
}
@Test
fun progressAndCompletionAreMirroredAndTheFileBecomesPlayable() =
runTest {
@@ -296,7 +346,10 @@ class DownloadRepositoryTest {
runTest {
seedLibrary()
writeFile(1, "s1", "audio")
runBlocking { repository.download(1, "s1") }
runBlocking {
repository.download(1, "s1")
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART).copy(downloadedCoverArts = listOf(ALBUM_ART))))
}
val id = engine.requests.single().first
runBlocking { repository.removeSource(1) }
@@ -304,6 +357,13 @@ class DownloadRepositoryTest {
assertTrue(engine.cancelled.contains(id))
assertFalse(file(1, "s1").exists())
assertTrue(allRows().isEmpty())
assertTrue(
db
.libraryDao()
.artistOnce(1, "ar1")!!
.downloadedCoverArts
.isEmpty(),
)
}
@Test
@@ -333,6 +393,95 @@ class DownloadRepositoryTest {
}
}
@Test
fun startingADownloadStoresTheProviderArtistImageAndKeepsItUntilTheDownloadGoes() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg")))
}
runBlocking { repository.download(1, "s1") }
assertTrue(requestedArt.contains("https://img.example/ar1.jpg"))
val key = artistImageKey(1, "ar1")
assertNotNull(artwork.uri(1, key))
runBlocking { repository.reconcile() }
assertNotNull(artwork.uri(1, key))
runBlocking { repository.remove(1, "s1") }
assertNull(artwork.uri(1, key))
}
@Test
fun providerArtworkForTheAlbumArtistSurvivesASweepAndGoesWithTheDownload() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertArtists(
listOf(
artist(ARTIST_ART, "https://img.example/track.jpg").copy(id = "ar-track"),
artist(ARTIST_ART, "https://img.example/album.jpg").copy(id = "ar-album"),
),
)
db.libraryDao().upsertAlbums(listOf(album(ALBUM_ART).copy(artistId = "ar-album")))
db.libraryDao().upsertSongs(listOf(db.libraryDao().songOnce(1, "s1")!!.copy(artistId = "ar-track")))
}
runBlocking { repository.download(1, "s1") }
val trackKey = artistImageKey(1, "ar-track")
val albumKey = artistImageKey(1, "ar-album")
assertNotNull(artwork.uri(1, trackKey))
assertNotNull(artwork.uri(1, albumKey))
runBlocking { repository.reconcile() }
assertNotNull(artwork.uri(1, trackKey))
assertNotNull(artwork.uri(1, albumKey))
runBlocking { repository.remove(1, "s1") }
assertNull(artwork.uri(1, trackKey))
assertNull(artwork.uri(1, albumKey))
}
@Test
fun aFailedProviderImageFetchCachesTheArtistCoverFallback() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg")))
}
failingArtUrls += "https://img.example/ar1.jpg"
runBlocking { repository.download(1, "s1") }
assertNull(artwork.uri(1, artistImageKey(1, "ar1")))
assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, false)))
assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, true)))
}
@Test
fun completingADownloadMaterializesOnlyItsAlbumForOfflineArtistArt() =
runTest {
seedLibrary()
repository.start()
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
engine.complete(id, total = 100)
writeFile(1, "s1", "audio")
runBlocking { repository.reconcile() }
await { row(1, "s1")?.status == DownloadStatus.Completed }
assertEquals(
listOf(ALBUM_ART),
db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts,
)
runBlocking { repository.remove(1, "s1") }
assertEquals(emptyList<String>(), db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts)
}
@Test
fun downloadingASongAlsoStoresTheArtworkOfPlaylistsThatContainIt() =
runTest {
@@ -647,7 +796,17 @@ class DownloadRepositoryTest {
),
)
db.libraryDao().upsertArtists(
listOf(Artist(sourceId = 1, id = "ar-guest", name = "Guest", albumCount = 0, starred = null, coverArt = null)),
listOf(
Artist(
sourceId = 1,
id = "ar-guest",
name = "Guest",
albumCount = 0,
starred = null,
coverArt = null,
songCount = 1,
),
),
)
}
@@ -682,7 +841,17 @@ class DownloadRepositoryTest {
),
)
db.libraryDao().upsertArtists(
listOf(Artist(sourceId = 1, id = "ar-guest", name = "Guest", albumCount = 0, starred = null, coverArt = null)),
listOf(
Artist(
sourceId = 1,
id = "ar-guest",
name = "Guest",
albumCount = 0,
starred = null,
coverArt = null,
songCount = 1,
),
),
)
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
}
@@ -910,7 +1079,9 @@ class DownloadRepositoryTest {
),
)
db.libraryDao().upsertArtists(
listOf(Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null)),
listOf(
Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null, songCount = 1),
),
)
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Queued))
@@ -953,6 +1124,53 @@ class DownloadRepositoryTest {
assertFalse(file(1, "s2").exists())
}
@Test
fun downloadedSongsFollowTheAlbumDiscAndTrackOrder() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertSongs(
listOf(
Song(
sourceId = 1,
id = "s0",
albumId = "al1",
artistId = "ar1",
title = "Alpha",
album = "Album",
artist = "Artist",
duration = 100,
track = 3,
disc = 1,
starred = null,
genre = null,
),
Song(
sourceId = 1,
id = "s3",
albumId = "al1",
artistId = "ar1",
title = "Zulu",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 2,
starred = null,
genre = null,
),
),
)
listOf("s0", "s1", "s2", "s3").forEach {
db.downloadDao().upsert(SongDownload(1, it, DownloadStatus.Completed))
}
}
val songs = runBlocking { repository.downloadedSongs(1).first() }
assertEquals(listOf("s1", "s2", "s0", "s3"), songs.map { it.songId })
}
@Test
fun theTreeGroupsAnAlbumUnderItsAlbumArtistNotEachTrackArtist() =
runTest {
@@ -1165,12 +1383,23 @@ class DownloadRepositoryTest {
songCount = 2,
)
private fun artist(coverArt: String?) =
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt)
private fun artist(
coverArt: String?,
artistImageUrl: String? = null,
) = Artist(
sourceId = 1,
id = "ar1",
name = "Artist",
albumCount = 1,
starred = null,
coverArt = coverArt,
artistImageUrl = artistImageUrl,
songCount = 2,
)
private fun seedLibrary() {
private fun seedLibrary(address: String = "http://localhost:4533/") {
runBlocking {
db.sourcesDao().upsertSource(Source(1, "source", "http://localhost:4533/", isActive = true, createdAt = 0))
db.sourcesDao().upsertSource(Source(1, "source", address, isActive = true, createdAt = 0))
db.sourcesDao().upsertSubsonicSource(SubsonicSource(1, "user", "pass"))
db.libraryDao().upsertSongs(
listOf(
@@ -1220,6 +1449,11 @@ class DownloadRepositoryTest {
songId: String,
): SongDownload? = runBlocking { db.downloadDao().find(sourceId, songId) }
private fun artistBio(
sourceId: Long,
artistId: String,
) = runBlocking { db.libraryDao().artistBioOnce(sourceId, artistId) }
private fun allRows(): List<SongDownload> = runBlocking { db.downloadDao().all() }
private fun writeFile(
@@ -2,6 +2,8 @@ package com.subtracks.data.repo
import android.content.Context
import android.content.res.Resources
import androidx.paging.PagingDataEvent
import androidx.paging.PagingDataPresenter
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
@@ -9,16 +11,27 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.Album
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.source.StarType
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
@@ -32,7 +45,10 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.CoroutineContext
@RunWith(AndroidJUnit4::class)
class LibraryRepositoryTest {
@@ -60,6 +76,7 @@ class LibraryRepositoryTest {
db,
sourceRepository,
NetworkServerActionSink(sourceRepository),
fakeUserPreferences(),
showMessage = { messages += it.resolve(resources) },
scope = scope,
)
@@ -163,6 +180,230 @@ class LibraryRepositoryTest {
assertEquals(2L, repository.albumSongOrdinal(sourceId, "al1", "s3"))
}
@Test
fun theAlbumGridPresentsTheWholeLibraryAndSurvivesAReload() =
runBlocking {
val sourceId = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
db.libraryDao().upsertAlbums((0 until 250).map { seedAlbum(sourceId, it) })
val presenterDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
val presenter = RecordingPresenter(presenterDispatcher)
val job =
launch {
repository
.albums(sourceId, AlbumSort.Name, false, StarredFilter.Any, "", false)
.collect { presenter.collectFrom(it) }
}
try {
awaitRefreshes(presenter, 1)
assertEquals(250, presented(presenter, presenterDispatcher))
db.libraryDao().upsertAlbums(listOf(seedAlbum(sourceId, 0).copy(playCount = 1)))
awaitRefreshes(presenter, 2)
assertEquals("a reload must not collapse the grid to one page", 250, presented(presenter, presenterDispatcher))
} finally {
job.cancelAndJoin()
presenterDispatcher.close()
}
}
@Test
fun artistAlbumsSeparatesAlbumCreditsFromTrackAppearances() =
runBlocking {
val sourceId = 1L
db.sourcesDao().upsertSource(
Source(id = sourceId, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
)
db.libraryDao().upsertAlbums(
listOf(
seedAlbum(sourceId, 1),
seedAlbum(sourceId, 2).copy(artistId = "ar2"),
),
)
db.libraryDao().upsertSongs(
listOf(
seedSong(sourceId).copy(albumId = "al-0001"),
seedSong(sourceId).copy(id = "s2", albumId = "al-0002"),
),
)
val albums = repository.artistAlbums(sourceId, "ar1").first()
assertEquals(listOf("al-0001"), albums.albumCredits.map { it.id })
assertEquals(listOf("al-0002"), albums.appearances.map { it.id })
assertEquals(2, albums.count)
}
@Test
fun downloadedArtistAlbumsKeepPerCreditVisibility() =
runBlocking {
val sourceId = 1L
db.sourcesDao().upsertSource(
Source(id = sourceId, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
)
db.libraryDao().upsertAlbums(
listOf(
seedAlbum(sourceId, 1),
seedAlbum(sourceId, 2).copy(artistId = "ar2"),
),
)
db.libraryDao().upsertSongs(
listOf(
seedSong(sourceId).copy(albumId = "al-0001"),
seedSong(sourceId).copy(id = "s2", albumId = "al-0002"),
seedSong(sourceId).copy(id = "s3", albumId = "al-0002", artistId = "ar2"),
seedSong(sourceId).copy(id = "s4", albumId = "al-0001", artistId = "ar2"),
),
)
db.downloadDao().upsert(SongDownload(sourceId, "s4", DownloadStatus.Completed))
val otherOnAlbumCredit = repository.artistAlbums(sourceId, "ar1", downloaded = true).first()
assertEquals(listOf("al-0001"), otherOnAlbumCredit.albumCredits.map { it.id })
assertEquals(emptyList<String>(), otherOnAlbumCredit.appearances.map { it.id })
db.downloadDao().upsert(SongDownload(sourceId, "s3", DownloadStatus.Completed))
val otherOnTrackCredit = repository.artistAlbums(sourceId, "ar1", downloaded = true).first()
assertEquals(listOf("al-0001"), otherOnTrackCredit.albumCredits.map { it.id })
assertEquals(emptyList<String>(), otherOnTrackCredit.appearances.map { it.id })
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed))
val creditedTrack = repository.artistAlbums(sourceId, "ar1", downloaded = true).first()
assertEquals(listOf("al-0001"), creditedTrack.albumCredits.map { it.id })
assertEquals(listOf("al-0002"), creditedTrack.appearances.map { it.id })
}
@Test
fun refreshingAnArtistBioStoresItAndUpdatesItOnTheNextOpen() =
runBlocking {
withSource { server ->
val sourceId = sourceRepository.activeSourceIdOnce()!!
server.enqueue(artistInfo("First bio"))
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
assertEquals("First bio", repository.artistBio(sourceId, "ar1").first()?.biography)
server.enqueue(artistInfo("Second bio"))
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
assertEquals("Second bio", repository.artistBio(sourceId, "ar1").first()?.biography)
val first = server.takeRequest()
assertEquals("/rest/getArtistInfo2.view", first.requestUrl!!.encodedPath)
assertEquals("ar1", first.requestUrl!!.queryParameter("id"))
assertEquals("/rest/getArtistInfo2.view", server.takeRequest().requestUrl!!.encodedPath)
}
}
@Test
fun anArtistWithoutABioStillRecordsTheFetchedState() =
runBlocking {
withSource { server ->
val sourceId = sourceRepository.activeSourceIdOnce()!!
server.enqueue(
MockResponse().setBody(
"<subsonic-response status=\"ok\"><artistInfo2/></subsonic-response>",
),
)
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
val bio = repository.artistBio(sourceId, "ar1").first()
assertNotNull(bio)
assertNull(bio?.biography)
}
}
@Test
fun anEmptyRefreshKeepsTheStoredBio() =
runBlocking {
withSource { server ->
val sourceId = sourceRepository.activeSourceIdOnce()!!
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, "ar1", "Kept"))
server.enqueue(
MockResponse().setBody(
"<subsonic-response status=\"ok\"><artistInfo2/></subsonic-response>",
),
)
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
assertEquals("Kept", repository.artistBio(sourceId, "ar1").first()?.biography)
}
}
@Test
fun biographiesKeepLineAndParagraphBreaks() {
assertEquals("Line one\nLine two", normalizeBiography("Line one\r\nLine two"))
assertEquals("Line one\nLine two", normalizeBiography("Line one\nLine two"))
assertEquals("One\n\nTwo", normalizeBiography("One Two"))
assertEquals("One\n\nTwo", normalizeBiography("One\n\nTwo"))
assertNull(normalizeBiography(" "))
}
@Test
fun aPreviouslyStoredBioIsNormalizedWhenRead() =
runBlocking {
withSource {
val sourceId = sourceRepository.activeSourceIdOnce()!!
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, "ar1", "One Two"))
assertEquals("One\n\nTwo", repository.artistBio(sourceId, "ar1").first()?.biography)
}
}
@Test
fun aFailedRefreshKeepsTheStoredBio() =
runBlocking {
withSource { server ->
val sourceId = sourceRepository.activeSourceIdOnce()!!
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, "ar1", "Kept"))
server.enqueue(MockResponse().setResponseCode(500))
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isFailure)
assertEquals("Kept", repository.artistBio(sourceId, "ar1").first()?.biography)
}
}
private suspend fun awaitRefreshes(
presenter: RecordingPresenter,
target: Int,
) {
withTimeout(30_000) {
while (presenter.refreshes.get() < target) delay(10)
}
}
private suspend fun presented(
presenter: RecordingPresenter,
dispatcher: CoroutineDispatcher,
): Int = withContext(dispatcher) { presenter.size }
private fun seedAlbum(
sourceId: Long,
index: Int,
) = Album(
sourceId = sourceId,
id = "al-" + index.toString().padStart(4, '0'),
artistId = "ar1",
name = "Album " + index.toString().padStart(4, '0'),
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
)
private class RecordingPresenter(
context: CoroutineContext,
) : PagingDataPresenter<Album>(context) {
val refreshes = AtomicInteger()
override suspend fun presentPagingDataEvent(event: PagingDataEvent<Album>) {
if (event is PagingDataEvent.Refresh) refreshes.incrementAndGet()
}
}
private suspend fun withSource(block: suspend (MockWebServer) -> Unit) {
val server = MockWebServer()
server.start()
@@ -198,6 +439,13 @@ class LibraryRepositoryTest {
genre = null,
)
private fun artistInfo(biography: String) =
MockResponse().setBody(
"<subsonic-response status=\"ok\"><artistInfo2>" +
"<biography>$biography</biography>" +
"</artistInfo2></subsonic-response>",
)
private fun ok() = MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")
private fun failed(code: Int) =
@@ -485,6 +485,80 @@ class QueueRepositoryTest {
repository.move(repository.snapshot(), from = 2, to = 1)
assertEquals(listOf("s1", "s5", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
val (entry, _) = repository.snapshot().locate(1)!!
assertEquals(QueueKind.Song, entry.kind)
}
@Test
fun movingAQueuedAlbumTrackKeepsItsAlbumEntry() =
runTest {
seedLibrary()
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
repository.move(repository.snapshot(), from = 2, to = 1)
val (entry, _) = repository.snapshot().locate(1)!!
assertEquals(QueueKind.Album, entry.kind)
assertEquals("al2", entry.refId)
assertEquals(1L, entry.rangeStart)
assertEquals(1L, entry.rangeEnd)
}
@Test
fun movingARangedAlbumTrackInUpNextKeepsTheAbsoluteOffsets() =
runTest {
seedLibrary()
db.libraryDao().upsertSongs(
listOf(
song("s6", "al1", track = 4, album = "First Album"),
song("s7", "al1", track = 5, album = "First Album"),
song("x1", "al3", track = 1),
),
)
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al2")))
repository.addUpNext(snapshot, currentPosition = 0, entry = albumRange(1, 4), playNext = false)
repository.addUpNext(
repository.snapshot(),
currentPosition = 0,
entry = repository.songEntry(1, "x1"),
playNext = false,
)
assertEquals(listOf("s4", "s2", "s3", "s6", "s7", "x1", "s5"), resolveAll(repository.snapshot()))
repository.move(repository.snapshot(), from = 4, to = 5)
var updated = repository.snapshot()
assertEquals(listOf("s4", "s2", "s3", "s6", "x1", "s7", "s5"), resolveAll(updated))
val moved = updated.locate(5)!!.first
assertEquals(QueueKind.Album, moved.kind)
assertEquals(4L, moved.rangeStart)
assertEquals(4L, moved.rangeEnd)
repository.move(repository.snapshot(), from = 5, to = 4)
updated = repository.snapshot()
assertEquals(listOf("s4", "s2", "s3", "s6", "s7", "x1", "s5"), resolveAll(updated))
assertTrue(updated.isAlbumContext(4))
}
@Test
fun movingAPlaylistTrackKeepsItAPinnedSong() =
runTest {
seedLibrary()
db.libraryDao().upsertPlaylistSongs(
listOf(
PlaylistSong(1, "p1", "s1", 0),
PlaylistSong(1, "p1", "s4", 1),
),
)
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
repository.move(repository.snapshot(), from = 0, to = 1)
val (entry, _) = repository.snapshot().locate(1)!!
assertEquals(QueueKind.Song, entry.kind)
assertEquals("s1", entry.refId)
}
@Test
@@ -853,11 +927,75 @@ class QueueRepositoryTest {
repository.move(repository.snapshot(), from = 0, to = 2)
val (entry, _) = repository.snapshot().locate(2)!!
assertEquals(QueueKind.Song, entry.kind)
assertEquals("s1", entry.refId)
assertEquals(QueueKind.Album, entry.kind)
assertEquals("al1", entry.refId)
assertEquals(0L, entry.rangeStart)
assertEquals(0L, entry.rangeEnd)
assertEquals("First Album", repository.sourceName(entry.kind, entry.sourceId, entry.refId))
}
@Test
fun movingWithinARangedAlbumKeepsTheAbsoluteOffsets() =
runTest {
seedLibrary()
db.libraryDao().upsertSongs(
listOf(
song("s6", "al1", track = 4, album = "First Album"),
song("s7", "al1", track = 5, album = "First Album"),
),
)
repository.snapshotAfter(listOf(albumRange(1, 4)))
assertEquals(listOf("s2", "s3", "s6", "s7"), resolveAll(repository.snapshot()))
repository.move(repository.snapshot(), from = 0, to = 2)
val snapshot = repository.snapshot()
assertEquals(listOf("s3", "s6", "s2", "s7"), resolveAll(snapshot))
val (entry, _) = snapshot.locate(2)!!
assertEquals(QueueKind.Album, entry.kind)
assertEquals(1L, entry.rangeStart)
assertEquals(1L, entry.rangeEnd)
}
@Test
fun movingBackwardWithinARangedAlbumKeepsTheAbsoluteOffsets() =
runTest {
seedLibrary()
db.libraryDao().upsertSongs(
listOf(
song("s6", "al1", track = 4, album = "First Album"),
song("s7", "al1", track = 5, album = "First Album"),
),
)
repository.snapshotAfter(listOf(albumRange(1, 4)))
repository.move(repository.snapshot(), from = 3, to = 0)
val snapshot = repository.snapshot()
assertEquals(listOf("s7", "s2", "s3", "s6"), resolveAll(snapshot))
val (entry, _) = snapshot.locate(0)!!
assertEquals(QueueKind.Album, entry.kind)
assertEquals(4L, entry.rangeStart)
assertEquals(4L, entry.rangeEnd)
}
@Test
fun movingAnAlbumTrackBackCompactsTheAlbumEntry() =
runTest {
seedLibrary()
repository.replace(listOf(repository.albumEntry(1, "al1")))
repository.move(repository.snapshot(), from = 0, to = 2)
repository.move(repository.snapshot(), from = 2, to = 0)
val snapshot = repository.snapshot()
assertEquals(listOf("s1", "s2", "s3"), resolveAll(snapshot))
val entry = snapshot.entries.single().entry
assertEquals(QueueKind.Album, entry.kind)
assertEquals(0L, entry.rangeStart)
assertEquals(2L, entry.rangeEnd)
}
@Test
fun anAlbumSplitByRemovalIsStillLabeledByItsAlbum() =
runTest {
@@ -11,6 +11,7 @@ import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences
@@ -33,6 +34,7 @@ import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -283,6 +285,35 @@ class SourceRepositoryTest {
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:"))
}
@Test
fun activeSourceBecomesReadyAfterThePreferencesLoad() =
runBlocking {
repository.addSource("nav", "http://a.example/", "u", "p", false)
val active = withTimeout(TEST_TIMEOUT_MS) { repository.activeSource.first { it != null } }
assertEquals(repository.activeSourceId().first(), active)
}
@Test
fun artistImageUsesTheNetworkOnlineAndOnlyCachedFilesOffline() =
runBlocking {
repository.addSource("nav", "http://a.example/", "u", "p", false)
val sourceId = awaitActiveSourceId()
val url = "https://img.example/ar1.jpg"
val online = withTimeout(TEST_TIMEOUT_MS) { repository.artistImage("ar1", url) }
assertEquals(url, online?.url)
val key = artistImageKey(sourceId, "ar1")
artwork.write(sourceId, key, byteArrayOf(1, 2, 3))
assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url)
repository.setOfflineMode(true)
withTimeout(TEST_TIMEOUT_MS) { repository.offline.first { it } }
assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url)
assertNull(repository.artistImage("ar2", "https://img.example/ar2.jpg"))
}
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! }
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
@@ -111,6 +111,37 @@ class SubsonicSourceTest {
)
}
@Test
fun artistBiographyReadsGetArtistInfo2() =
runBlocking {
server.enqueue(
MockResponse().setBody(
"<subsonic-response status=\"ok\"><artistInfo2>" +
"<biography>From Last.fm</biography>" +
"</artistInfo2></subsonic-response>",
),
)
val biography = SubsonicSource(1, client()).artistBiography("ar1")
assertEquals("From Last.fm", biography)
val request = server.takeRequest()
assertEquals("/rest/getArtistInfo2.view", request.requestUrl!!.encodedPath)
assertEquals("ar1", request.requestUrl!!.queryParameter("id"))
}
@Test
fun artistBiographyIsNullWhenTheServerHasNone() =
runBlocking {
server.enqueue(
MockResponse().setBody(
"<subsonic-response status=\"ok\"><artistInfo2/></subsonic-response>",
),
)
assertNull(SubsonicSource(1, client()).artistBiography("ar1"))
}
@Test
fun artistsAreEmittedInBoundedBatches() =
runBlocking {
@@ -256,6 +287,75 @@ class SubsonicSourceTest {
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
}
@Test
fun songsKeepTheOpenSubsonicReplayGain() =
runBlocking {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl!!
if (url.encodedPath != "/rest/search3.view") return MockResponse().setResponseCode(404)
return MockResponse().setBody(
"<subsonic-response status=\"ok\"><searchResult3>" +
"<song id=\"s1\" title=\"Gained\">" +
"<replayGain trackGain=\"-3.2\" albumGain=\"-2.1\" trackPeak=\"0.98\" albumPeak=\"0.99\"/>" +
"</song></searchResult3></subsonic-response>",
)
}
}
val songs = SubsonicSource(1, client()).songs().toList().flatten()
assertEquals(-3.2f, songs.single().trackGain)
assertEquals(-2.1f, songs.single().albumGain)
assertEquals(0.98f, songs.single().trackPeak)
assertEquals(0.99f, songs.single().albumPeak)
}
@Test
fun albumSongsKeepTheOpenSubsonicReplayGain() =
runBlocking {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl!!
return when (url.encodedPath) {
"/rest/search3.view" -> {
MockResponse().setBody(failed(10, "Required parameter is missing"))
}
"/rest/getAlbumList2.view" -> {
MockResponse().setBody(
"<subsonic-response status=\"ok\"><albumList2>" +
"<album id=\"al1\" name=\"Album\" artist=\"Artist\" artistId=\"ar1\" songCount=\"1\"/>" +
"</albumList2></subsonic-response>",
)
}
"/rest/getAlbum.view" -> {
MockResponse().setBody(
"<subsonic-response status=\"ok\"><album id=\"al1\">" +
"<song id=\"s1\" title=\"Gained\">" +
"<replayGain trackGain=\"-3.2\" albumGain=\"-2.1\" trackPeak=\"0.98\" albumPeak=\"0.99\"/>" +
"</song></album></subsonic-response>",
)
}
else -> {
MockResponse().setResponseCode(404)
}
}
}
}
val songs = SubsonicSource(1, client()).songs().toList().flatten()
assertEquals(-3.2f, songs.single().trackGain)
assertEquals(-2.1f, songs.single().albumGain)
assertEquals(0.98f, songs.single().trackPeak)
assertEquals(0.99f, songs.single().albumPeak)
}
@Test
fun entitiesWithEmptyIdsAreDropped() =
runBlocking {
@@ -608,6 +708,40 @@ class SubsonicSourceTest {
assertTrue(maxInFlight.get() in 2..DEFAULT_FETCH_CONCURRENCY)
}
@Test
fun albumSongsAreEmittedInBoundedBatches() =
runBlocking {
val albumCount = 200
val songsPerAlbum = 3
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val url = request.requestUrl!!
return when (url.encodedPath) {
"/rest/search3.view" -> {
MockResponse().setBody(failed(10, "Required parameter is missing"))
}
"/rest/getAlbumList2.view" -> {
MockResponse().setBody(albumListOf(albumCount))
}
"/rest/getAlbum.view" -> {
MockResponse().setBody(albumWithSongs(url.queryParameter("id")!!, songsPerAlbum))
}
else -> {
MockResponse().setResponseCode(404)
}
}
}
}
val batches = SubsonicSource(1, client()).songs().toList().map { it.size }
assertEquals(listOf(500, 100), batches)
}
@Test
fun playlistEntriesWithoutIdsAreSkipped() =
runBlocking {
@@ -705,6 +839,13 @@ class SubsonicSourceTest {
"<song id=\"$albumId-s1\" title=\"One\"/>" +
"</album></subsonic-response>"
private fun albumWithSongs(
albumId: String,
count: Int,
) = "<subsonic-response status=\"ok\"><album id=\"$albumId\">" +
(1..count).joinToString("") { "<song id=\"$albumId-s$it\" title=\"Title $it\"/>" } +
"</album></subsonic-response>"
private fun failed(
code: Int,
message: String,
@@ -1,6 +1,8 @@
package com.subtracks.data.source.subsonic
import com.subtracks.data.model.ArtistRef
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.xml.sax.Attributes
@@ -12,7 +14,7 @@ class SubsonicXmlTest {
xml: String,
tag: String,
create: (Attributes) -> T,
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
): List<T> {
val result = mutableListOf<T>()
SubsonicXml.readEntities(
@@ -30,9 +32,41 @@ class SubsonicXmlTest {
xml,
tag = "album",
create = { SubsonicXml.albumDraft(1, it) },
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
onChild = SubsonicXml::albumChild,
).map { it.toAlbum() }
private fun songs(xml: String) =
parse(
xml,
tag = "song",
create = { SubsonicXml.songDraft(1, it) },
onChild = SubsonicXml::songChild,
).map { it.toSong() }
@Test
fun readsTheArtistBiography() {
val biography =
SubsonicXml.artistBiography(
(
"<subsonic-response status=\"ok\"><artistInfo2>" +
"<biography> Radiohead &amp; friends </biography>" +
"</artistInfo2></subsonic-response>"
).byteInputStream(Charsets.UTF_8),
)
assertEquals("Radiohead & friends", biography)
}
@Test
fun anArtistWithoutABiographyReadsAsNull() {
val biography =
SubsonicXml.artistBiography(
"<subsonic-response status=\"ok\"><artistInfo2/></subsonic-response>".byteInputStream(Charsets.UTF_8),
)
assertNull(biography)
}
@Test
fun mapsArtists() {
val artists =
@@ -40,7 +74,8 @@ class SubsonicXmlTest {
"""
<subsonic-response status="ok">
<artists>
<artist id="ar1" name="Radiohead" albumCount="9" starred="2020-01-02T03:04:05.000Z"/>
<artist id="ar1" name="Radiohead" albumCount="9" starred="2020-01-02T03:04:05.000Z"
artistImageUrl="https://img.example/radiohead.jpg"/>
<artist id="ar2" name="Portishead" albumCount="3"/>
</artists>
</subsonic-response>
@@ -54,6 +89,8 @@ class SubsonicXmlTest {
assertEquals(7L, artists[0].sourceId)
assertEquals(9L, artists[0].albumCount)
assertEquals(1577934245L, artists[0].starred)
assertEquals("https://img.example/radiohead.jpg", artists[0].artistImageUrl)
assertEquals(null, artists[1].artistImageUrl)
assertEquals(null, artists[1].starred)
}
@@ -99,6 +136,257 @@ class SubsonicXmlTest {
assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), albums.single().discTitles)
}
@Test
fun mapsAlbumArtistsFromRepeatedOpenSubsonicRefs() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Split" artist="A • B" songCount="1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</album>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals(
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
albums.single().artists,
)
}
@Test
fun mapsNestedAlbumArtistRefs() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Split" songCount="1">
<artists>
<artist id="ar1" name="A"/>
<artist id="ar2" name="B"/>
</artists>
</album>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals(
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
albums.single().artists,
)
}
@Test
fun fallsBackToTheSingularAlbumArtist() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Kid A" artist="Radiohead" artistId="ar1" songCount="10"/>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals(listOf(ArtistRef("ar1", "Radiohead")), albums.single().artists)
}
@Test
fun mapsSongArtistsAndIgnoresAlbumArtists() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" artist="A • B" albumId="al1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
<albumArtists id="ar3" name="Album Artist"/>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals(
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
songs.single().artists,
)
}
@Test
fun ignoresNestedArtistsOutsideTheArtistsContainer() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" albumId="al1">
<artists>
<artist id="ar1" name="A"/>
</artists>
<contributors>
<artist id="ar9" role="composer"/>
</contributors>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals(listOf(ArtistRef("ar1", "A")), songs.single().artists)
}
@Test
fun mapsNestedSongArtistRefs() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" albumId="al1">
<artists>
<artist id="ar1" name="A"/>
<artist id="ar2" name="B"/>
</artists>
<albumArtists>
<artist id="ar3" name="Album Artist"/>
</albumArtists>
<contributors>
<artist id="ar4" role="composer"/>
</contributors>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals(
listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
songs.single().artists,
)
}
@Test
fun fallsBackToThePluralNamesWhenTheLegacyDisplayArtistIsBlank() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Split" songCount="1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</album>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals("A • B", albums.single().albumArtist)
assertEquals(listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), albums.single().artists)
}
@Test
fun prefersTheOpenSubsonicDisplayArtistOverThePluralNames() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="Split" displayArtist="A feat. B" songCount="1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</album>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
assertEquals("A feat. B", albums.single().albumArtist)
}
@Test
fun fallsBackToThePluralSongArtistsWhenTheLegacyNameIsBlank() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" albumId="al1">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals("A • B", songs.single().artist)
assertEquals(listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), songs.single().artists)
}
@Test
fun prefersTheOpenSubsonicDisplayArtistForSongs() {
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Collab" albumId="al1" displayArtist="A feat. B">
<artists id="ar1" name="A"/>
<artists id="ar2" name="B"/>
</song>
</subsonic-response>
""".trimIndent(),
)
assertEquals("A feat. B", songs.single().artist)
}
@Test
fun mapsSortNamesAndFallsBackToTheDisplayName() {
val artists =
parse(
"""
<subsonic-response status="ok">
<artists>
<artist id="ar1" name="宇多田ヒカル" sortName="Utada Hikaru" albumCount="1"/>
<artist id="ar2" name="No Sort" albumCount="1"/>
</artists>
</subsonic-response>
""".trimIndent(),
tag = "artist",
create = { SubsonicXml.artist(7, it) },
)
assertEquals("Utada Hikaru", artists[0].sortName)
assertEquals("No Sort", artists[1].sortName)
}
@Test
fun mapsAlbumAndSongSortNames() {
val albums =
albums(
"""
<subsonic-response status="ok">
<albumList2>
<album id="al1" name="First Love" sortName="First Love" artist="宇多田ヒカル" songCount="1"/>
</albumList2>
</subsonic-response>
""".trimIndent(),
)
val songs =
songs(
"""
<subsonic-response status="ok">
<song id="s1" title="Automatic" sortName="Automatic" artist="宇多田ヒカル" albumId="al1"/>
</subsonic-response>
""".trimIndent(),
)
assertEquals("First Love", albums.single().sortName)
assertEquals("Automatic", songs.single().sortName)
}
@Test
fun parsesTimestampsWithFractionsAndOffsets() {
assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z"))
@@ -130,6 +418,7 @@ class SubsonicXmlTest {
assertEquals(1, songs.size)
assertEquals("Idioteque", songs[0].title)
assertEquals(300L, songs[0].duration)
assertEquals(listOf(ArtistRef("ar1", "Radiohead")), songs[0].artists)
assertEquals(0L, entries[0].position)
assertEquals("sg1", entries[0].songId)
}
@@ -156,51 +445,155 @@ class SubsonicXmlTest {
@Test
fun mapsSongGenresAndIgnoresEmptyOnes() {
val xml =
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Multi">
<genres name="Rock"/>
<genres name="Electronic"/>
<genres name=""/>
<genres name=" "/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent()
val songs =
parse(
xml,
tag = "song",
create = { SubsonicXml.songDraft(1, it) },
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
).map { it.toSong() }
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Multi">
<genres name="Rock"/>
<genres name="Electronic"/>
<genres name=""/>
<genres name=" "/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(listOf("Rock", "Electronic"), songs.single().genres)
}
@Test
fun ignoresSongGenresThatAreNotDirectChildren() {
val xml =
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Nested">
<wrapper><genres name="Rock"/></wrapper>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent()
fun mapsSongReplayGain() {
val songs =
parse(
xml,
tag = "song",
create = { SubsonicXml.songDraft(1, it) },
onChild = { draft, name, attrs -> if (name == "genres") draft.addGenre(attrs) },
).map { it.toSong() }
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Gained">
<replayGain trackGain="-3.2" albumGain="-2.1" trackPeak="0.98" albumPeak="0.99"/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
val song = songs.single()
assertEquals(-3.2f, song.trackGain)
assertEquals(-2.1f, song.albumGain)
assertEquals(0.98f, song.trackPeak)
assertEquals(0.99f, song.albumPeak)
}
@Test
fun leavesMissingReplayGainFieldsUnset() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Track gain only">
<replayGain trackGain="-3.2"/>
</song>
<song id="s2" title="No gain"/>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(-3.2f, songs[0].trackGain)
assertEquals(null, songs[0].albumGain)
assertEquals(null, songs[0].trackPeak)
assertEquals(null, songs[0].albumPeak)
assertEquals(null, songs[1].trackGain)
assertEquals(null, songs[1].albumGain)
assertEquals(null, songs[1].trackPeak)
assertEquals(null, songs[1].albumPeak)
}
@Test
fun mapsReplayGainPeaksWithoutGains() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Peaks only">
<replayGain trackPeak="0.98" albumPeak="0.99"/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(null, songs.single().trackGain)
assertEquals(null, songs.single().albumGain)
assertEquals(0.98f, songs.single().trackPeak)
assertEquals(0.99f, songs.single().albumPeak)
}
@Test
fun keepsTheFieldsOfEveryReplayGainElement() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Split">
<replayGain trackGain="-3.2"/>
<replayGain trackPeak="0.98"/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(-3.2f, songs.single().trackGain)
assertEquals(0.98f, songs.single().trackPeak)
}
@Test
fun ignoresNonFiniteReplayGainValues() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Junk">
<replayGain trackGain="Infinity" albumGain="NaN" trackPeak="1e40" albumPeak="-Infinity"/>
</song>
<song id="s2" title="Mostly fine">
<replayGain trackGain="-3.2" albumGain="NaN" trackPeak="0.98"/>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(null, songs[0].trackGain)
assertEquals(null, songs[0].albumGain)
assertEquals(null, songs[0].trackPeak)
assertEquals(null, songs[0].albumPeak)
assertEquals(-3.2f, songs[1].trackGain)
assertEquals(null, songs[1].albumGain)
assertEquals(0.98f, songs[1].trackPeak)
}
@Test
fun ignoresSongGenresThatAreNotDirectChildren() {
val songs =
songs(
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="Nested">
<wrapper><genres name="Rock"/></wrapper>
</song>
</searchResult3>
</subsonic-response>
""".trimIndent(),
)
assertEquals(emptyList<String>(), songs.single().genres)
}
@@ -352,6 +745,29 @@ class SubsonicXmlTest {
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
}
@Test
fun mapsPlaylistOwnerAndSharing() {
val playlists =
parse(
"""
<subsonic-response status="ok">
<playlists>
<playlist id="pl1" name="Shared" owner="austin" public="true" songCount="2" duration="120"/>
<playlist id="pl2" name="Private" owner="austin" public="false" songCount="1" duration="60"/>
<playlist id="pl3" name="Numeric" owner="austin" public="1" songCount="1" duration="60"/>
</playlists>
</subsonic-response>
""".trimIndent(),
tag = "playlist",
create = { SubsonicXml.playlist(1, it) },
)
assertEquals("austin", playlists[0].owner)
assertTrue(playlists[0].shared)
assertEquals(false, playlists[1].shared)
assertTrue(playlists[2].shared)
}
private class CountingInputStream(
input: InputStream,
) : FilterInputStream(input) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
package com.subtracks.playback
import android.os.Looper
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.SimpleBasePlayer
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@OptIn(UnstableApi::class)
@RunWith(AndroidJUnit4::class)
class KnownDurationPlayerTest {
private fun player(
declaredDurationMs: Long?,
live: Boolean = true,
): Player =
object : SimpleBasePlayer(Looper.getMainLooper()) {
override fun getState(): State {
val metadata =
MediaMetadata
.Builder()
.setTitle("Track")
.setDurationMs(declaredDurationMs)
.build()
val item =
MediaItemData
.Builder("track")
.setMediaItem(
MediaItem
.Builder()
.setMediaId("s1")
.setMediaMetadata(metadata)
.build(),
).setDurationUs(PLAYER_DURATION_US)
.setLiveConfiguration(if (live) MediaItem.LiveConfiguration.UNSET else null)
.build()
return State
.Builder()
.setPlaylist(listOf(item))
.build()
}
}
@Test
fun aLiveTrackWithADeclaredDurationIsReportedFinite() {
val player = player(declaredDurationMs = 100_000L)
assertFalse(KnownDurationPlayer(player).isCurrentMediaItemLive())
}
@Test
fun aLiveTrackWithoutADeclaredDurationStaysLive() {
val player = player(declaredDurationMs = null)
assertTrue(KnownDurationPlayer(player).isCurrentMediaItemLive())
}
@Test
fun aFiniteTrackWithADeclaredDurationStaysFinite() {
val player = player(declaredDurationMs = 100_000L, live = false)
assertFalse(KnownDurationPlayer(player).isCurrentMediaItemLive())
}
@Test
fun theWrappedDurationIsThePlayersNotTheMetadata() {
val player = player(declaredDurationMs = 100_000L)
assertEquals(PLAYER_DURATION_MS, KnownDurationPlayer(player).getDuration())
}
private companion object {
const val PLAYER_DURATION_MS = 200_000L
const val PLAYER_DURATION_US = PLAYER_DURATION_MS * 1000
}
}
@@ -2,6 +2,7 @@ package com.subtracks.playback
import androidx.media3.common.C
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.ArtistRef
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -54,4 +55,68 @@ class MediaItemTest {
assertEquals("Song", item.mediaMetadata.title.toString())
assertEquals(CoverArtArtwork.uri("al1"), item.mediaMetadata.artworkUri)
}
@Test
fun theItemCarriesTheReplayGain() {
val gain = ReplayGain(trackGain = -3.2f, albumGain = -2.1f, trackPeak = 0.98f, albumPeak = 0.99f)
val item =
queueMediaItem(
song.copy(replayGain = gain),
local = null,
stream = "http://server/stream?s=s1",
transcode = false,
)
assertEquals(gain, item.replayGain())
}
@Test
fun theItemCarriesEveryArtist() {
val artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B"))
val item =
queueMediaItem(
song.copy(artists = artists),
local = null,
stream = "http://server/stream?s=s1",
transcode = false,
)
assertEquals(artists, item.artistRefs())
}
@Test
fun theItemCarriesItsGainContext() {
val item =
queueMediaItem(
song.copy(albumContext = true),
local = null,
stream = "http://server/stream?s=s1",
transcode = false,
)
assertEquals(true, item.albumContext())
assertEquals(false, queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false).albumContext())
}
@Test
fun anItemWithoutGainReadsBackAsNoGain() {
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
assertEquals(ReplayGain(), item.replayGain())
}
@Test
fun aZeroGainIsNotMistakenForNoGain() {
val item =
queueMediaItem(
song.copy(replayGain = ReplayGain(trackGain = 0f, trackPeak = 0.5f)),
local = null,
stream = "http://server/stream?s=s1",
transcode = false,
)
assertEquals(ReplayGain(trackGain = 0f, trackPeak = 0.5f), item.replayGain())
}
}
@@ -218,6 +218,61 @@ class PlaybackControllerTest {
assertNull(runBlocking { controller.previousItem() })
}
@Test
fun previousItemWrapsToTheEndWithRepeatAll() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.cycleRepeat()
assertEquals(RepeatMode.All, controller.state.value.repeat)
assertEquals("s3", runBlocking { controller.previousItem()?.id })
}
@Test
fun upcomingItemIsNullAtTheEndOfTheQueue() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
assertNull(runBlocking { controller.upcomingItem() })
}
@Test
fun upcomingItemWrapsToTheStartWithRepeatAll() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.cycleRepeat()
assertEquals(RepeatMode.All, controller.state.value.repeat)
assertEquals("s1", runBlocking { controller.upcomingItem()?.id })
}
@Test
fun upcomingItemWrapsToTheStartWithRepeatOne() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.cycleRepeat()
controller.cycleRepeat()
assertEquals(RepeatMode.One, controller.state.value.repeat)
assertEquals("s1", runBlocking { controller.upcomingItem()?.id })
}
@Test
fun previousTrackMovesBackEvenOncePreviousWouldRestart() {
seedAlbum(3, sourceId = 1)
@@ -424,6 +479,296 @@ class PlaybackControllerTest {
}
}
@Test
fun albumTracksAndQueuedSinglesCarryDifferentGainContexts() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await { handle.items.any { it.id == "s1" } }
assertTrue(handle.items.first { it.id == "s1" }.albumContext)
controller.playNext(1, QueueKind.Song, "x1")
await { handle.items.any { it.id == "x1" } }
assertFalse(handle.items.first { it.id == "x1" }.albumContext)
}
@Test
fun queuedAlbumsCarryAlbumGain() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await { handle.items.any { it.id == "s1" } }
controller.playNext(1, QueueKind.Album, "al2")
await { handle.items.any { it.id == "x1" } }
assertTrue(handle.items.first { it.id == "x1" }.albumContext)
}
@Test
fun shuffledAlbumTracksKeepAlbumContext() {
seedShuffledQueue(seed = 5L)
assertTrue(handle.items.isNotEmpty())
assertTrue(handle.items.all { it.albumContext })
}
@Test
fun movingAnAlbumTrackKeepsItsAlbumGainContext() {
seedAlbum(60, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await { handle.items.any { it.id == "s1" } }
runBlocking { controller.move(0, 40) }
await { controller.state.value.position == 40L }
assertTrue(handle.items.first { it.id == "s1" }.albumContext)
val (entry, _) = runBlocking { queues.snapshot().locate(40)!! }
assertEquals(QueueKind.Album, entry.kind)
assertEquals("al1", entry.refId)
}
@Test
fun undoingARemovalRestoresTheTracksAlbumGainContext() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await { handle.items.any { it.id == "s2" } }
runBlocking { controller.removeAt(1) }
await { handle.items.none { it.id == "s2" } }
runBlocking { controller.undo() }
await { handle.items.any { it.id == "s2" } }
assertTrue(handle.items.first { it.id == "s2" }.albumContext)
}
@Test
fun movingAContextTrackAcrossUpNextKeepsPlayerAndQueueInSync() {
seedAlbum(3, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { handle.items.any { it.id == "x1" } }
runBlocking { controller.move(0, 3) }
await { controller.state.value.position == 2L }
val expected =
runBlocking {
val snapshot = queues.snapshot()
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
}
assertEquals(expected, handle.items.map { it.id })
assertEquals(
"s1",
controller.state.value.item
?.id,
)
}
@Test
fun contextDragsWithAnUpNextBlockKeepPlayerAndQueueInSync() {
seedAlbum(5, sourceId = 1)
seedSong("x1", "al2")
seedSong("x2", "al2")
val pairs =
listOf(
0L to 3L,
3L to 0L,
0L to 6L,
6L to 0L,
3L to 4L,
4L to 3L,
4L to 6L,
6L to 4L,
1L to 2L,
2L to 1L,
)
for ((from, to) in pairs) {
controller.playAlbum(1, "al1", 0)
await { handle.items.map { it.id } == listOf("s1", "s2", "s3", "s4", "s5") }
controller.addToQueue(1, QueueKind.Song, "x1")
controller.addToQueue(1, QueueKind.Song, "x2")
await { handle.items.map { it.id } == listOf("s1", "x1", "x2", "s2", "s3", "s4", "s5") }
runBlocking { controller.move(from, to) }
val expected =
runBlocking {
val snapshot = queues.snapshot()
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
}
assertEquals(expected, handle.items.map { it.id })
assertEquals(
"s1",
controller.state.value.item
?.id,
)
assertEquals(expected.indexOf("s1").toLong(), controller.state.value.position)
}
}
@Test
fun draggingWithinTheContextRunsAroundTheBlockKeepsPlayerAndQueueInSync() {
seedAlbum(5, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 2)
await {
controller.state.value.item
?.id == "s3"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
runBlocking { controller.move(0, 1) }
var expected =
runBlocking {
val snapshot = queues.snapshot()
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
}
assertEquals(expected, handle.items.map { it.id })
assertEquals(
"s3",
controller.state.value.item
?.id,
)
assertEquals(expected.indexOf("s3").toLong(), controller.state.value.position)
runBlocking { controller.move(4, 5) }
expected =
runBlocking {
val snapshot = queues.snapshot()
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
}
assertEquals(expected, handle.items.map { it.id })
assertEquals(
"s3",
controller.state.value.item
?.id,
)
assertEquals(expected.indexOf("s3").toLong(), controller.state.value.position)
}
@Test
fun movingThePlayingUpNextTrackInTheBlockKeepsPlayerAndQueueInSync() {
seedAlbum(5, sourceId = 1)
seedSong("x1", "al2")
seedSong("x2", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
controller.addToQueue(1, QueueKind.Song, "x2")
await { handle.items.map { it.id } == listOf("s1", "x1", "x2", "s2", "s3", "s4", "s5") }
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
runBlocking { controller.move(1, 2) }
val expected =
runBlocking {
val snapshot = queues.snapshot()
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
}
assertEquals(expected, handle.items.map { it.id })
assertEquals(
"x1",
controller.state.value.item
?.id,
)
assertEquals(expected.indexOf("x1").toLong(), controller.state.value.position)
}
@Test
fun draggingAContextTrackWhileAnUpNextTrackPlaysKeepsPlayerAndQueueInSync() {
seedAlbum(5, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { runBlocking { controller.upcomingItem()?.id } == "x1" }
controller.next()
await {
controller.state.value.item
?.id == "x1"
}
runBlocking { controller.move(0, 6) }
val expected =
runBlocking {
val snapshot = queues.snapshot()
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
}
assertEquals(expected, handle.items.map { it.id })
assertEquals(
"x1",
controller.state.value.item
?.id,
)
assertEquals(expected.indexOf("x1").toLong(), controller.state.value.position)
}
@Test
fun movingAContextTrackAcrossUpNextFarFromTheWindowKeepsPlayerAndQueueInSync() {
seedAlbum(60, sourceId = 1)
seedSong("x1", "al2")
controller.playAlbum(1, "al1", 30)
await {
controller.state.value.item
?.id == "s31"
}
controller.addToQueue(1, QueueKind.Song, "x1")
await { handle.items.any { it.id == "x1" } }
runBlocking { controller.move(0, 59) }
val expected =
runBlocking {
val snapshot = queues.snapshot()
(0 until snapshot.size).mapNotNull { queues.itemAt(snapshot, it)?.song?.id }
}
val current = expected.indexOf("s31")
await { controller.state.value.position == current.toLong() }
val first = (current - 25).coerceAtLeast(0)
val last = (current + 25).coerceAtMost(expected.lastIndex)
assertEquals(expected.subList(first, last + 1), handle.items.map { it.id })
assertEquals(
"s31",
controller.state.value.item
?.id,
)
}
@Test
fun tappingAContextTrackWhileAQueuedTrackPlaysKeepsTheQueue() {
seedAlbum(3, sourceId = 1)
@@ -1213,6 +1558,25 @@ class PlaybackControllerTest {
@Test
fun movingThePlayingContextTrackStillResolvesAnAlbumTitle() {
seedAlbum(3, sourceId = 1)
runBlocking {
db.libraryDao().upsertAlbums(
listOf(
Album(
sourceId = 1,
id = "al1",
artistId = "ar1",
name = "Album",
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 3,
),
),
)
}
controller.playAlbum(1, "al1", 1)
await {
@@ -2207,6 +2571,38 @@ class PlaybackControllerTest {
)
}
@Test
fun aSingleTrackQueueHasNoAdjacentArtwork() {
seedAlbum(1, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.cycleRepeat()
assertEquals(RepeatMode.All, controller.state.value.repeat)
assertNull(runBlocking { controller.upcomingItem() })
assertNull(runBlocking { controller.previousItem() })
}
@Test
fun aSingleDownloadedTrackHasNoAdjacentArtworkWhenOffline() {
seedAlbum(1, sourceId = 1)
markDownloaded(1, "s1")
setOffline()
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
controller.cycleRepeat()
assertEquals(RepeatMode.All, controller.state.value.repeat)
assertNull(runBlocking { controller.upcomingItem() })
assertNull(runBlocking { controller.previousItem() })
}
@Test
fun repeatAllLoopsAfterEndingWithRepeatOff() {
seedAlbum(2, sourceId = 1)
@@ -0,0 +1,106 @@
package com.subtracks.playback
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Song
import org.junit.Assert.assertEquals
import org.junit.Test
class QueueItemTest {
@Test
fun carriesTheSongsReplayGain() {
val song =
Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Song",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
trackGain = -3.2f,
albumGain = -2.1f,
trackPeak = 0.98f,
albumPeak = 0.99f,
)
val item = AlbumSongItem(song, coverArt = "al1").toQueueItem()
assertEquals(
ReplayGain(trackGain = -3.2f, albumGain = -2.1f, trackPeak = 0.98f, albumPeak = 0.99f),
item.replayGain,
)
}
@Test
fun carriesTheAlbumsGainContextWhenAsked() {
val song =
Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Song",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
)
val item = AlbumSongItem(song, coverArt = null).toQueueItem(albumContext = true)
assertEquals(true, item.albumContext)
}
@Test
fun carriesEveryArtist() {
val song =
Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Song",
album = "Album",
artist = "A • B",
duration = 100,
track = 1,
disc = 1,
starred = null,
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
)
val item = AlbumSongItem(song, coverArt = null).toQueueItem()
assertEquals(listOf("A", "B"), item.artists.map { it.name })
}
@Test
fun aSongWithoutGainCarriesNoGain() {
val item =
AlbumSongItem(
Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = "ar1",
title = "Song",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 1,
starred = null,
),
coverArt = null,
).toQueueItem()
assertEquals(ReplayGain(), item.replayGain)
}
}
@@ -0,0 +1,99 @@
package com.subtracks.playback
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.prefs.ReplayGainMode
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ReplayGainControllerTest {
private val applied = mutableListOf<Float>()
private val controller = ReplayGainController { applied += it }
@Test
fun appliesTheItemsGain() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
controller.applyItem(item("a", trackGain = -6.0206f))
assertEquals(0.5f, applied.last(), TOLERANCE)
}
@Test
fun recomputesWhenTheSettingsChange() {
controller.applyItem(item("a", trackGain = -6.0206f))
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 6.0206f))
assertEquals(1f, applied.last(), TOLERANCE)
}
@Test
fun switchingOffRestoresUnityGain() {
controller.applyItem(item("a", trackGain = -6.0206f))
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
assertEquals(0.5f, applied.last(), TOLERANCE)
controller.setSettings(ReplayGainSettings(ReplayGainMode.Off, 0f))
assertEquals(1f, applied.last(), TOLERANCE)
}
@Test
fun autoModeFollowsTheItemsGainContext() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Auto, 0f))
controller.applyItem(item("a", trackGain = -6.0206f, albumGain = -1f))
assertEquals(0.5f, applied.last(), TOLERANCE)
controller.applyItem(item("b", trackGain = -1f, albumGain = -6.0206f, albumContext = true))
assertEquals(0.5f, applied.last(), TOLERANCE)
}
@Test
fun anItemWithoutGainPlaysAtUnity() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
controller.applyItem(item("a", trackGain = -6.0206f))
assertEquals(0.5f, applied.last(), TOLERANCE)
controller.applyItem(item("b", trackGain = null))
assertEquals(1f, applied.last(), TOLERANCE)
}
@Test
fun theLatestItemLeavesItsGainApplied() {
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
controller.applyItem(item("a", trackGain = 6.0206f, trackPeak = 0.5f))
assertEquals(2f, applied.last(), TOLERANCE)
controller.applyItem(item("b", trackGain = -6.0206f))
assertEquals(0.5f, applied.last(), TOLERANCE)
}
private fun item(
id: String,
trackGain: Float?,
trackPeak: Float? = null,
albumGain: Float? = null,
albumContext: Boolean = false,
) = queueMediaItem(
QueueItem(
id = id,
title = id,
artist = null,
album = null,
coverArtId = null,
replayGain = ReplayGain(trackGain = trackGain, albumGain = albumGain, trackPeak = trackPeak),
albumContext = albumContext,
),
local = null,
stream = "http://server/$id",
transcode = false,
)
private companion object {
const val TOLERANCE = 0.001f
}
}
@@ -0,0 +1,206 @@
package com.subtracks.playback
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.audio.AudioProcessor.AudioFormat
import androidx.media3.common.audio.AudioProcessor.StreamMetadata
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.source.SinglePeriodTimeline
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.prefs.ReplayGainMode
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import java.nio.ByteBuffer
import java.nio.ByteOrder
@OptIn(UnstableApi::class)
@RunWith(AndroidJUnit4::class)
class ReplayGainProcessorTest {
@Test
fun scalesSixteenBitSamples() {
val processor = processor(gain = 0.5f)
processor.queueInput(sixteenBit(100, -200, 1000, -1000))
assertEquals(listOf(50, -100, 500, -500), readShorts(processor.getOutput()))
}
@Test
fun passesSamplesThroughAtUnityGain() {
val processor = processor(gain = 1f)
processor.queueInput(sixteenBit(1234, -4321))
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
}
@Test
fun saturatesInsteadOfWrappingWhenTheGainOverflows() {
val processor = processor(gain = 4f)
processor.queueInput(sixteenBit(20000, -20000))
assertEquals(listOf(32767, -32768), readShorts(processor.getOutput()))
}
@Test
fun scalesFloatSamples() {
val processor = processor(gain = 0.5f, encoding = C.ENCODING_PCM_FLOAT)
processor.queueInput(floatBuffer(1f, -0.5f))
assertEquals(listOf(0.5f, -0.25f), readFloats(processor.getOutput()))
}
@Test
fun passesAnUnsupportedEncodingThroughUnchanged() {
val processor = processor(gain = 2f, encoding = C.ENCODING_PCM_24BIT)
processor.queueInput(ByteBuffer.wrap(byteArrayOf(1, 2, 3)))
assertEquals(listOf<Byte>(1, 2, 3), readBytes(processor.getOutput()))
}
@Test
fun passesSamplesThroughWhenTheGainIsNaN() {
val processor = processor(gain = Float.NaN)
processor.queueInput(sixteenBit(1234, -4321))
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
}
@Test
fun passesSamplesThroughWhenTheGainIsInfinite() {
val processor = processor(gain = Float.POSITIVE_INFINITY)
processor.queueInput(sixteenBit(1234, -4321))
assertEquals(listOf(1234, -4321), readShorts(processor.getOutput()))
}
@Test
fun keepsTheInputFormatSoTheSinkKeepsItInThePipeline() {
val processor = ReplayGainProcessor()
val configured = processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
assertEquals(C.ENCODING_PCM_16BIT, configured.encoding)
assertEquals(true, processor.isActive())
}
@Test
fun aFlushReportsTheItemBeingFlushed() {
val reported = mutableListOf<MediaItem>()
val item = item("a", trackGain = -6.0206f)
ReplayGainProcessor { reported += it }.flush(streamMetadata(item))
assertEquals(listOf(item), reported)
}
@Test
fun aFlushWithoutAPeriodLeavesTheGainAlone() {
lateinit var processor: ReplayGainProcessor
val controller = ReplayGainController { processor.setLinearGain(it) }
processor = ReplayGainProcessor { controller.applyItem(it) }
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
processor.flush(streamMetadata(item("a", trackGain = -6.0206f)))
processor.flush(StreamMetadata.DEFAULT)
processor.queueInput(sixteenBit(1000))
assertEquals(listOf(500), readShorts(processor.getOutput()))
}
@Test
fun aFlushMovesTheGainToTheNextItem() {
lateinit var processor: ReplayGainProcessor
val controller = ReplayGainController { processor.setLinearGain(it) }
processor = ReplayGainProcessor { controller.applyItem(it) }
controller.setSettings(ReplayGainSettings(ReplayGainMode.Track, 0f))
processor.configure(AudioFormat(SAMPLE_RATE, CHANNELS, C.ENCODING_PCM_16BIT))
processor.flush(streamMetadata(item("a", trackGain = -6.0206f)))
processor.queueInput(sixteenBit(1000))
assertEquals(listOf(500), readShorts(processor.getOutput()))
processor.flush(streamMetadata(item("b", trackGain = 6.0206f, trackPeak = 0.5f)))
processor.queueInput(sixteenBit(1000))
assertEquals(listOf(2000), readShorts(processor.getOutput()))
}
private fun processor(
gain: Float,
encoding: Int = C.ENCODING_PCM_16BIT,
): ReplayGainProcessor =
ReplayGainProcessor().apply {
setLinearGain(gain)
configure(AudioFormat(SAMPLE_RATE, CHANNELS, encoding))
flush(StreamMetadata.DEFAULT)
}
private fun item(
id: String,
trackGain: Float,
trackPeak: Float? = null,
): MediaItem =
queueMediaItem(
QueueItem(
id = id,
title = id,
artist = null,
album = null,
coverArtId = null,
replayGain = ReplayGain(trackGain = trackGain, trackPeak = trackPeak),
),
local = null,
stream = "http://server/$id",
transcode = false,
)
private fun streamMetadata(item: MediaItem): StreamMetadata {
val timeline = SinglePeriodTimeline(DURATION_US, false, false, false, null, item)
return StreamMetadata
.Builder()
.setTimeline(timeline)
.setPeriodUid(timeline.getUidOfPeriod(0))
.build()
}
private fun sixteenBit(vararg samples: Int): ByteBuffer =
ByteBuffer.allocate(samples.size * 2).order(ByteOrder.nativeOrder()).apply {
samples.forEach { putShort(it.toShort()) }
flip()
}
private fun floatBuffer(vararg samples: Float): ByteBuffer =
ByteBuffer.allocate(samples.size * 4).order(ByteOrder.nativeOrder()).apply {
samples.forEach { putFloat(it) }
flip()
}
private fun readShorts(buffer: ByteBuffer): List<Int> =
buildList {
while (buffer.remaining() >= 2) add(buffer.short.toInt())
}
private fun readFloats(buffer: ByteBuffer): List<Float> =
buildList {
while (buffer.remaining() >= 4) add(buffer.float)
}
private fun readBytes(buffer: ByteBuffer): List<Byte> =
buildList {
while (buffer.hasRemaining()) add(buffer.get())
}
private companion object {
const val SAMPLE_RATE = 44_100
const val CHANNELS = 2
const val DURATION_US = 1_000_000L
}
}

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