114 Commits
Author SHA1 Message Date
austinried 11276c2e1f playback: revert the restored session prepare
Preparing the restored window on launch was meant to make the paused
session reachable from Bluetooth and Automate. The follow-up on the issue
shows the notification appears, but the car's play command still does not
reach the app until the phone is unlocked, and the speculative prepare
buffered up to 50 seconds of the restored track on every launch while
claiming the media controls whenever the app opened.

Removing the prepare also removes the error gate it needed, which could
hide genuine playback errors while paused.

Reverts #230.
2026-11-05 13:13:22 +00:00
austinried eb55858cc4 playback: preserve null keys and ranged duplicate ranks 2026-11-04 13:15:54 +00:00
austinried 95ad993996 playback: resolve shuffled windows without flat ID lists 2026-11-04 13:06:45 +00:00
austinried 11abd4afbd ui: share bulk download deletion confirmation 2026-11-04 12:16:06 +00:00
austinried 69f41b3459 playback: collapse the restore guard into one null chain 2026-11-04 11:12:07 +00:00
austinried 09baaeb77d playback: keep speculative prepare failures quiet
A restore now prepares the saved window before any play gesture, so a
network failure at launch reached the error listener and showed a toast
even though the user had not asked to play. Only report playback errors
when playback was requested.

The new tests now wait for the prepare operation rather than the window
contents, which can be observed a poll before the prepare lands.
2026-11-04 11:12:07 +00:00
austinried 484cc55385 playback: prepare the restored queue so the session starts on launch
Restoring the saved queue left the player idle, so the media session had no
notification and Bluetooth or Automate saw no active player until playback
began. Prepare the restored window without autoplaying, which surfaces the
paused session when the app opens. A queue from another source stays stopped,
and an offline queue without downloads has no window to prepare.

Closes austinried/subtracks#202
2026-11-04 11:12:07 +00:00
austinried 599924bdd0 test: share Room database and repository fixtures 2026-11-04 11:08:31 +00:00
austinried d6ff3ee086 build: fetch Robolectric's Android SDK jar from the mirror
Robolectric resolves android-all-instrumented itself at test time with
MavenArtifactFetcher, whose default repository is repo1.maven.org. The
Gradle mirror in settings.gradle.kts never applied to that fetch, so the
CI egress IP still got HTTP 429 there.

That fetcher also stores the jar in ~/.m2/repository, outside the
~/.gradle/caches directory CI restores, so every ephemeral runner
downloaded it again. Point robolectric.dependency.repo.url at the same
mirror for every Test task and cache the Robolectric Maven repository in
both workflows, keyed by the Robolectric version and the configured SDK.
2026-11-04 10:21:49 +00:00
austinried b2a1716be2 library: generate artist credit queries from one definition 2026-11-04 10:11:17 +00:00
austinried 51792f836b test: wait for the repository's active source in artist art tests
awaitActiveSourceId() watched the DAO's activeSourceId flow, which turns
non-null as soon as the source row is written. SourceRepository sets its
in-memory active source in a separate collector, so artistImage() could
still see null and the online URL assertion failed. Wait on the
repository's own activeSource readiness signal instead.
2026-11-03 09:08:54 +00:00
austinried 446a35d26e sync: declare upsert columns and values together 2026-11-03 01:21:28 +00:00
austinried b18e0891f6 db: tighten the paging observer and filter comments 2026-11-03 01:03:45 +00:00
austinried c53f1aef06 db: correct the paging filter comment
The downloaded statements are no longer the old hand-written text, and
RawQuery invalidation comes from observedEntities, not the filter text.
2026-11-03 01:03:45 +00:00
austinried 87e0609c05 db: drop the constant downloaded filter flag
The downloaded statements only run with the filter on, so the
(? = 0 OR EXISTS (...)) guards and the CASE around the downloaded counts
were always true. Keep those statements direct and remove the unused bind.
2026-11-03 01:03:45 +00:00
austinried c3a5382c27 db: bind named parameters directly and cover the repository dispatch
SQLite gives a repeated named parameter one index, so rawQuery can pass the
SQL through unchanged and bind the distinct names in first-appearance order
instead of rewriting them to positional placeholders.

Use named arguments for the repository dispatch, since the adjacent
descending/downloaded booleans were easy to transpose, and add a repository
test that collects the downloaded album grid. Fix the query-timing labels so
the unqualified rows measure the same default path as before.
2026-11-03 01:03:45 +00:00
austinried e8cbb93714 db: drop order constants the paging builders replaced
The deleted order methods and the old plan tests were the last users of the
plain name/artist order constants and the reverse sort-mode variants.
2026-11-03 01:03:45 +00:00
austinried 6f1e05d15a db: carry the sort-name mode through the paging builders
Rebase follow-up for the new SortNameMode preference: pass it into the
shared album/artist order functions so downloaded pages honour it too,
cover the new _plain indexes in the downloaded artist plan test, and add a
downloaded-mode ordering test.
2026-11-03 01:03:45 +00:00
austinried c165a6cc88 db: pin the downloaded artist count access path in the plan test
Assert the sourceId index as well as the in-memory sort for the downloaded
Artist album count order, and fold the top-level plan helper into plan().
2026-11-03 01:03:45 +00:00
austinried 18c24ca566 db: cover downloaded paging invalidation and filters
Add tests for the downloaded artist and playlist paging sources now that
their observed entities are hand-picked, and assert the downloaded
search/starred filters directly. The downloaded artist plan test uses the
real select list and documents the computed album count's in-memory sort,
which the hand-written query also had.
2026-11-03 01:03:45 +00:00
austinried 8d6f7e2341 db: collapse paged library queries into one builder per entity
Replace the thirty hand-written order variants with one query builder per
entity plus a default and a downloaded @RawQuery entry point. The
repository's sort dispatch trees and the duplicated positional filters in
LibraryPaging are gone, and the SQL now lives in multi-line strings with
named parameters.

The generated statements are identical to the previous ones modulo
whitespace: the downloaded variants keep the same CASE/download conditions,
and the observed entities match what Room inferred for the old queries. A
small helper rewrites the named parameters to RoomRawQuery's positional
form, including the repeated :sourceId bindings.
2026-11-03 01:03:45 +00:00
austinried 29935b7bee build: mirror Maven Central for dependency resolution
The CI runner's egress IP is rate-limited by repo.maven.apache.org (HTTP 429 on a Robolectric native runtime jar), and a missing Gradle cache then breaks the run. Try Google's Maven Central mirror first and fall back to the canonical repository.
2026-11-01 05:45:25 +00:00
austinried 4bfe67ec19 build: add the nightly res through the directories set
AGP 9 deprecates AndroidSourceDirectorySet.srcDir() in favour of the directories mutable set, which was the last source-set deprecation warning in the build.
2026-11-01 05:35:04 +00:00
austinried 060a5f036f ci: key the test music cache by the demo album ids
The album id is the server id, so use it for the download, the local folder and the prune fixture instead of maintaining a separate remoteId mapping. The cache key moves to v3 because v2 may already hold folders named after the old local ids.
2026-11-01 03:19:17 +00:00
austinried c169549ef4 ci: take integration test music from the Navidrome demo
demo.subsonic.org no longer accepts the guest1 account, so the harness downloaded an XML error page and unzip failed before Gradle ran. Download the same three albums from demo.navidrome.org with the demo account, fetch each album's cover art into folder.jpg, and match the album title the files actually carry (Retroconnaissance, without the EP suffix). Bump the music cache key so stale copies from the old source are not reused.
2026-11-01 03:12:01 +00:00
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
137 changed files with 18605 additions and 2139 deletions
+5
View File
@@ -40,6 +40,11 @@ runs:
path: ~/.gradle/caches
key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-v2-
- uses: actions/cache@v6
with:
path: ~/.m2/repository/org/robolectric
key: robolectric-m2-v1-${{ hashFiles('gradle/libs.versions.toml', 'app/src/test/resources/robolectric.properties') }}
restore-keys: robolectric-m2-v1-
- id: signing
if: inputs.sign == 'true'
shell: bash
+1 -1
View File
@@ -50,5 +50,5 @@ jobs:
- uses: actions/cache@v6
with:
path: .integration/music
key: integration-music-v1
key: integration-music-v3
- run: nix develop .#integration --command nu tools/integration-test.nu
+4 -2
View File
@@ -85,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:
@@ -123,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.
+5 -3
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\"")
}
@@ -119,7 +119,7 @@ android {
}
sourceSets {
getByName("debug").res.srcDir("src/nightly/res")
getByName("debug").res.directories.add("src/nightly/res")
}
buildTypes {
@@ -181,6 +181,8 @@ val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
tasks.withType<Test>().configureEach {
// Robolectric fetches its Android SDK jar itself, outside Gradle's repositories.
systemProperty("robolectric.dependency.repo.url", providers.gradleProperty("mavenCentralMirrorUrl").get())
if (name.endsWith("UnitTest")) {
filter {
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
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
@@ -56,7 +56,7 @@ class SubsonicSourceIntegrationTest(
assertNotNull(kosmo.starred)
assertTrue(kosmo.created <= System.currentTimeMillis() / 1000)
assertNull(albums.first { it.name == "Retroconnaissance EP" }.starred)
assertNull(albums.first { it.name == "Retroconnaissance" }.starred)
assertNull(albums.first { it.name == "I Don't Know What I'm Doing" }.starred)
}
@@ -92,7 +92,7 @@ class SubsonicSourceIntegrationTest(
.filter { it.artistId == artists.first { a -> a.name == "Ugress" }.id }
.map { it.name }
.sorted()
assertEquals(listOf("Kosmonaut", "Retroconnaissance EP"), ugressAlbums)
assertEquals(listOf("Kosmonaut", "Retroconnaissance"), ugressAlbums)
val bradAlbums =
albums
@@ -22,7 +22,7 @@ class SubsonicWriteIntegrationTest(
.albums()
.toList()
.flatten()
.first { it.name == "Retroconnaissance EP" }
.first { it.name == "Retroconnaissance" }
source.setStar(StarType.Album, album.id, true)
try {
@@ -94,7 +94,7 @@ class SubsonicWriteIntegrationTest(
.songs()
.toList()
.flatten()
.first { it.album == "Retroconnaissance EP" }
.first { it.album == "Retroconnaissance" }
source.setStar(StarType.Song, song.id, true)
try {
@@ -1,11 +1,7 @@
package com.subtracks.data.sync
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.model.Source
import com.subtracks.data.source.subsonic.TestServers
import kotlinx.coroutines.runBlocking
@@ -18,12 +14,7 @@ class MultiSourceSyncIntegrationTest {
@Test
fun syncsEveryServerIntoOneLibraryWithoutInterference() =
runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val db = createTestDatabase()
try {
TestServers.all.forEachIndexed { index, server ->
val id = index + 1L
@@ -1,11 +1,7 @@
package com.subtracks.data.sync
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.model.Source
import com.subtracks.data.source.subsonic.SubsonicClient
import com.subtracks.data.source.subsonic.SubsonicSource
@@ -28,12 +24,7 @@ class PruneSyncIntegrationTest {
val removed = File(musicDir, REMOVED_ALBUM_DIR)
check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" }
val context = ApplicationProvider.getApplicationContext<Context>()
val db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val db = createTestDatabase()
try {
db.sourcesDao().upsertSource(
Source(id = 1, name = "prune", address = baseUrl, isActive = true, createdAt = 0),
@@ -93,7 +84,7 @@ class PruneSyncIntegrationTest {
?: error("$name is not set; run the integration harness (tools/integration-test.nu)")
private companion object {
const val REMOVED_ALBUM_DIR = "197"
const val REMOVED_ALBUM_NAME = "Retroconnaissance EP"
const val REMOVED_ALBUM_DIR = "7GZYpfn8wypaTR00LWeNI4"
const val REMOVED_ALBUM_NAME = "Retroconnaissance"
}
}
@@ -1,11 +1,7 @@
package com.subtracks.data.sync
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.model.Source
import com.subtracks.data.source.subsonic.TestServers
import kotlinx.coroutines.runBlocking
@@ -19,12 +15,7 @@ class SyncServiceIntegrationTest {
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
runBlocking {
servers.forEach { server ->
val context = ApplicationProvider.getApplicationContext<Context>()
val db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val db = createTestDatabase()
try {
db.sourcesDao().upsertSource(
Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0),
@@ -0,0 +1,127 @@
package com.subtracks.data.db
import androidx.room3.RoomRawQuery
internal val CREDITED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery())
internal val DOWNLOADED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true))
internal val DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST =
albumCreditRows(AlbumCreditQuery(downloaded = true, artistScoped = true, selectAlbums = true))
internal val DOWNLOADED_COVER_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true, coverOnly = true))
internal val ARTIST_COVER_CREDITS = albumCreditRows(AlbumCreditQuery(coverOnly = true))
internal val ARTIST_COVER_CREDITS_FOR_ARTIST = albumCreditRows(AlbumCreditQuery(coverOnly = true, artistScoped = true))
private data class AlbumCreditQuery(
val artistScoped: Boolean = false,
val coverOnly: Boolean = false,
val downloaded: Boolean = false,
val selectAlbums: Boolean = false,
)
private fun albumCreditRows(query: AlbumCreditQuery): String =
if (query.selectAlbums) {
listOf(
primaryAlbumCredits(query),
albumRelationCredits(query),
trackRelationCredits(query),
trackCredits(query),
)
} else {
listOf(
primaryAlbumCredits(query),
albumRelationCredits(query),
trackCredits(query),
trackRelationCredits(query),
)
}.joinToString(if (query.downloaded) " UNION " else " UNION ALL ")
private fun primaryAlbumCredits(query: AlbumCreditQuery): String =
buildString {
append(
when {
query.selectAlbums -> "SELECT albums.* FROM albums "
query.downloaded -> "SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums "
else -> "SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType FROM albums "
},
)
append("WHERE albums.sourceId = :sourceId AND ${query.directArtist("albums.artistId")} ")
append(query.coverFilter())
if (query.downloaded) append("AND $ALBUM_DOWNLOADED ")
}.trim()
private fun albumRelationCredits(query: AlbumCreditQuery): String =
buildString {
append(
when {
query.selectAlbums -> "SELECT albums.* FROM album_artists "
query.downloaded -> "SELECT albums.id, album_artists.artistId FROM album_artists "
else -> "SELECT album_artists.artistId, albums.id, 'album' FROM album_artists "
},
)
append("JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId ")
append("WHERE album_artists.sourceId = :sourceId ")
if (query.artistScoped) append("AND album_artists.artistId = :artistId ")
append(query.coverFilter())
if (query.downloaded) append("AND $ALBUM_DOWNLOADED ")
}.trim()
private fun trackCredits(query: AlbumCreditQuery): String =
buildString {
append(
when {
query.selectAlbums -> "SELECT albums.* FROM songs "
query.downloaded -> "SELECT songs.albumId, songs.artistId FROM songs "
else -> "SELECT songs.artistId, songs.albumId, 'track' FROM songs "
},
)
append(SONG_ALBUM_JOIN)
if (query.downloaded) append(TRACK_DOWNLOAD_JOIN)
append("WHERE songs.sourceId = :sourceId AND ${query.directArtist("songs.artistId")} ")
append(query.coverFilter())
if (query.downloaded) append("AND sd.status = 'Completed' ")
}.trim()
private fun trackRelationCredits(query: AlbumCreditQuery): String =
buildString {
append(
when {
query.selectAlbums -> "SELECT albums.* FROM song_artists "
query.downloaded -> "SELECT songs.albumId, song_artists.artistId FROM song_artists "
else -> "SELECT song_artists.artistId, songs.albumId, 'track' FROM song_artists "
},
)
append("JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId ")
append(SONG_ALBUM_JOIN)
if (query.downloaded) append(TRACK_DOWNLOAD_JOIN)
append("WHERE song_artists.sourceId = :sourceId ")
if (query.artistScoped) append("AND song_artists.artistId = :artistId ")
append(query.coverFilter())
if (query.downloaded) append("AND sd.status = 'Completed' ")
}.trim()
private fun AlbumCreditQuery.directArtist(column: String): String = if (artistScoped) "$column = :artistId" else "$column IS NOT NULL"
private fun AlbumCreditQuery.coverFilter(): String = if (coverOnly) "AND albums.coverArt IS NOT NULL " else ""
private const val SONG_ALBUM_JOIN = "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId "
private const val TRACK_DOWNLOAD_JOIN = "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id "
private const val ALBUM_DOWNLOADED =
"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')"
internal fun downloadedArtistArtQuery(
sourceId: Long,
artistIds: List<String>,
): RoomRawQuery {
val bindings = mutableMapOf<String, Any?>("sourceId" to sourceId)
val placeholders =
artistIds
.mapIndexed { index, artistId ->
val name = "artistId$index"
bindings[name] = artistId
":$name"
}.ifEmpty { listOf("NULL") }
return rawQuery(DOWNLOADED_ARTIST_ART_UPDATE_SQL.replace(":artistIds", placeholders.joinToString(",")), bindings)
}
@@ -2,6 +2,7 @@ package com.subtracks.data.db
import android.content.Context
import androidx.room3.Room
import androidx.room3.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import com.subtracks.log.Log
@@ -11,6 +12,9 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase {
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
.setDriver(driver)
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
.addCallback(SqliteLifecycleLogging)
.addSubtracksDatabaseCallbacks()
.build()
}
internal fun RoomDatabase.Builder<SubtracksDatabase>.addSubtracksDatabaseCallbacks(): RoomDatabase.Builder<SubtracksDatabase> =
addCallback(ArtistSearchTriggerOptimization).addCallback(SqliteLifecycleLogging)
@@ -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")
@@ -15,31 +15,13 @@ import kotlinx.coroutines.flow.Flow
// downloads are visible even though they lead no albums of their own.
// Starts from songs so status totals include every song the artist is credited on.
internal const val ARTIST_DOWNLOAD_ROWS =
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " +
"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 " +
"UNION ALL " +
"SELECT album_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE songs.sourceId = :sourceId " +
"UNION ALL " +
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " +
"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 " +
"UNION ALL " +
"SELECT song_artists.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
"FROM songs " +
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
"WHERE songs.sourceId = :sourceId"
"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 " +
@@ -180,12 +162,15 @@ interface DownloadDao {
@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>
@@ -10,14 +10,21 @@ import androidx.room3.Transaction
import androidx.room3.Upsert
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumArtistRef
import com.subtracks.data.model.AlbumSearch
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.ArtistSearch
import com.subtracks.data.model.Disc
import com.subtracks.data.model.DiscKey
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSearch
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.PlaylistSongItem
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongArtistRef
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import kotlinx.coroutines.flow.Flow
@@ -35,25 +42,6 @@ internal const val DOWNLOADED_SONG =
"EXISTS (SELECT 1 FROM song_downloads sd WHERE sd.sourceId = playlist_songs.sourceId " +
"AND sd.songId = playlist_songs.songId AND sd.status = 'Completed')"
internal const val ALBUMS_FILTER =
"FROM albums 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 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')) " +
"AND (:search = '' " +
"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(sortName), lower(:search)) > 0 " +
"OR instr(lower(albumArtistSort), lower(:search)) > 0))) "
internal const val ALBUM_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ARTIST = "albumArtistSort COLLATE NOCASE, year, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_ARTIST_REVERSED =
"albumArtistSort COLLATE NOCASE DESC, year DESC, sortName 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"
@@ -69,61 +57,129 @@ internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, sortName C
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 DOWNLOADED_ARTIST_ALBUMS =
"SELECT albums.id FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId = artists.id " +
"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 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 = artists.id " +
"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 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = artists.id " +
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL " +
"UNION " +
"SELECT songs.albumId FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = artists.id " +
"AND sd.status = 'Completed' AND songs.albumId IS NOT NULL"
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"
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id"
internal const val ARTIST_CREDITED =
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
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 $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 instr(lower(sortName), lower(:search)) > 0))) "
// 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"
// 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 ($DOWNLOADED_ARTIST_ALBUMS)" +
") ELSE artists.albumCount END AS albumCount, " +
"artists.starred, artists.coverArt, artists.playCount, artists.played, artists.songCount, artists.sortName, " +
"artists.credited "
internal 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 ARTIST_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
internal val ALBUM_CREDIT_ALBUM_IDS =
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
"WHERE artistId = :artistId AND creditType = 'album'"
internal val DOWNLOADED_ARTIST_ALBUMS =
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
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 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 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 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 val ARTIST_COVER_ARTS_SQL =
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
internal 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 ARTIST_COLUMNS_TAIL =
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, " +
"artists.downloadedCoverArts, artists.playCount, artists.played, artists.songCount, " +
"artists.sortName, artists.credited"
internal const val ARTIST_COLUMNS =
"artists.sourceId, artists.id, artists.name, artists.albumCount, $ARTIST_COLUMNS_TAIL"
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"
@@ -131,26 +187,13 @@ internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, s
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"
internal const val PLAYLISTS_FILTER =
"FROM playlists WHERE sourceId = :sourceId " +
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM playlist_songs ps " +
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId " +
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id AND sd.status = 'Completed')) " +
"AND (:search = '' " +
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
"WHERE playlist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) "
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"
// With the Downloaded filter on, count the songs that actually have a download so the number under
// a playlist matches the songs its detail screen lists.
internal const val PLAYLISTS_SELECT =
"SELECT playlists.sourceId, playlists.id, playlists.name, playlists.comment, playlists.coverArt, " +
"CASE WHEN :downloadedFilter = 1 THEN (" +
"SELECT COUNT(DISTINCT ps.songId) FROM playlist_songs ps " +
"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 "
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 PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
@@ -179,6 +222,39 @@ internal const val SONG_SEARCH_FILTER =
"(length(:search) >= $SEARCH_MIN_LENGTH AND songs.rowid IN (SELECT rowid FROM song_search " +
"WHERE song_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"'))"
internal val ARTIST_AGGREGATES_SQL =
"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)"
internal val ALBUMS_FOR_ARTIST_DOWNLOADED =
"SELECT * FROM ($DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST) ORDER BY year DESC, sortName COLLATE NOCASE, id"
@Dao
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
interface LibraryDao {
@@ -211,62 +287,46 @@ interface LibraryDao {
)
suspend fun recomputeAlbumPlayData(sourceId: Long)
// The credited albums are the union of the lead id and the relation table, so a shared album
// counts once per artist rather than twice.
@Query(
"WITH credited AS (" +
"SELECT albums.artistId AS artistId, albums.id AS albumId, " +
"albums.playCount AS playCount, albums.played AS played " +
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION " +
"SELECT album_artists.artistId, albums.id, albums.playCount, albums.played " +
"FROM album_artists JOIN albums " +
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId" +
"), totals AS (" +
"SELECT artistId AS id, SUM(playCount) AS playCount, MAX(played) AS played " +
"FROM credited GROUP BY artistId" +
") " +
"UPDATE artists SET playCount = c.playCount, played = c.played " +
"FROM (SELECT artists.id AS id, COALESCE(totals.playCount, 0) AS playCount, totals.played AS played " +
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
"WHERE artists.sourceId = :sourceId) c " +
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
"AND (artists.playCount != c.playCount OR artists.played IS NOT c.played)",
)
suspend fun recomputeArtistPlayData(sourceId: Long)
@RawQuery
suspend fun recomputeArtistAggregates(query: RoomRawQuery): Int
// Stored so the download badge query can report an artist's total without walking every song.
@Query(
"WITH credited AS (" +
"SELECT artistId, songId FROM song_artists WHERE sourceId = :sourceId " +
"UNION " +
"SELECT album_artists.artistId AS artistId, songs.id AS songId " +
"FROM album_artists " +
"JOIN songs ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId" +
"), counts AS (" +
"SELECT artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY artistId" +
") " +
"UPDATE artists SET songCount = c.songCount, credited = c.credited " +
"FROM (SELECT artists.id AS id, COALESCE(counts.songCount, 0) AS songCount, " +
"((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 counts ON counts.id = artists.id " +
"WHERE artists.sourceId = :sourceId) c " +
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
"AND (artists.songCount != c.songCount OR artists.credited != c.credited)",
)
suspend fun recomputeArtistSongCounts(sourceId: Long)
suspend fun recomputeArtistAggregates(sourceId: Long) {
recomputeArtistAggregates(rawQuery(ARTIST_AGGREGATES_SQL, mapOf("sourceId" to sourceId)))
}
@RawQuery
suspend fun recomputeArtistArt(query: RoomRawQuery): Int
suspend fun recomputeArtistArt(sourceId: Long) {
recomputeArtistArt(rawQuery(ARTIST_ART_UPDATE_SQL, mapOf("sourceId" to sourceId)))
}
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
fun artistCoverArts(query: RoomRawQuery): Flow<List<String>>
fun artistCoverArts(
sourceId: Long,
artistId: String,
): Flow<List<String>> = artistCoverArts(rawQuery(ARTIST_COVER_ARTS_SQL, mapOf("sourceId" to sourceId, "artistId" to artistId)))
@RawQuery
suspend fun recomputeDownloadedArtistArt(query: RoomRawQuery): Int
suspend fun recomputeDownloadedArtistArt(
sourceId: Long,
artistIds: List<String>,
) {
recomputeDownloadedArtistArt(downloadedArtistArtQuery(sourceId, artistIds))
}
@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(
@@ -293,11 +353,14 @@ interface LibraryDao {
"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) " +
"OR id IN (SELECT artistId FROM album_artists WHERE sourceId = :sourceId AND albumId = :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,
)
@@ -307,6 +370,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
@@ -316,9 +385,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
@@ -339,7 +408,7 @@ interface LibraryDao {
): Flow<List<Album>>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
"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(
@@ -357,7 +426,7 @@ interface LibraryDao {
): Flow<List<Album>>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND playCount > 0 AND $ARTIST_CREDITED " +
"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(
@@ -397,22 +466,24 @@ interface LibraryDao {
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
"ORDER BY sortName 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 AND $ARTIST_CREDITED " +
"ORDER BY sortName 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(
@@ -429,12 +500,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.sortName 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(
@@ -568,245 +640,35 @@ interface LibraryDao {
@RawQuery(observedEntities = [Album::class])
fun albumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
// 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")
fun albumsByName(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
// These observe every table the statements read; the sets match what Room inferred for the
// old downloaded @Query methods, so download and library writes invalidate as before.
@RawQuery(observedEntities = [Album::class, Song::class, SongDownload::class, AlbumSearch::class])
fun downloadedAlbumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED")
fun albumsByNameReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@RawQuery(
observedEntities = [
Album::class,
Song::class,
SongDownload::class,
AlbumArtistRef::class,
SongArtistRef::class,
Artist::class,
Source::class,
ArtistSearch::class,
],
)
fun downloadedArtistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST")
fun albumsByArtist(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED")
fun albumsByArtistReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
fun albumsByYear(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR_REVERSED")
fun albumsByYearReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED")
fun albumsByRecentlyAdded(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED_REVERSED")
fun albumsByRecentlyAddedReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED")
fun albumsByStarred(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED_REVERSED")
fun albumsByStarredReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_FREQUENT")
fun albumsByFrequent(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_FREQUENT_REVERSED")
fun albumsByFrequentReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_RECENT")
fun albumsByRecent(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_RECENT_REVERSED")
fun albumsByRecentReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME")
fun artistsByName(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED")
fun artistsByNameReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
fun artistsByAlbumCount(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED")
fun artistsByAlbumCountReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED")
fun artistsByStarred(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED_REVERSED")
fun artistsByStarredReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_FREQUENT")
fun artistsByFrequent(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_FREQUENT_REVERSED")
fun artistsByFrequentReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_RECENT")
fun artistsByRecent(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_RECENT_REVERSED")
fun artistsByRecentReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist>
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME")
fun playlistsByName(
sourceId: Long,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist>
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME_REVERSED")
fun playlistsByNameReversed(
sourceId: Long,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist>
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED")
fun playlistsByAdded(
sourceId: Long,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist>
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED_REVERSED")
fun playlistsByAddedReversed(
sourceId: Long,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist>
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED")
fun playlistsByUpdated(
sourceId: Long,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist>
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED_REVERSED")
fun playlistsByUpdatedReversed(
sourceId: Long,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist>
@RawQuery(observedEntities = [PlaylistSong::class, SongDownload::class, Playlist::class, PlaylistSearch::class])
fun downloadedPlaylistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
@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>
@@ -818,13 +680,13 @@ interface LibraryDao {
fun homeRecentlyAddedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId AND played IS NOT NULL AND $ARTIST_CREDITED " +
"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 AND $ARTIST_CREDITED " +
"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>
@@ -883,7 +745,7 @@ interface LibraryDao {
fun downloadedAlbums(sourceId: Long): PagingSource<Int, Album>
@Query(
"SELECT artists.* FROM artists " +
"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 " +
@@ -982,18 +844,33 @@ 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?>
@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 COALESCE(" +
@@ -1025,28 +902,21 @@ interface LibraryDao {
songId: String,
): Flow<List<String>>
@Query(
"SELECT * FROM (" +
"SELECT albums.* FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
"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 " +
"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 " +
"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 " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
)
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
fun albumsForArtist(query: RoomRawQuery): Flow<List<Album>>
fun albumsForArtist(
sourceId: Long,
artistId: String,
): Flow<List<Album>>
): Flow<List<Album>> = albumsForArtist(rawQuery(ALBUMS_FOR_ARTIST, mapOf("sourceId" to sourceId, "artistId" to artistId)))
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
fun albumCreditAlbumIds(query: RoomRawQuery): Flow<List<String>>
fun albumCreditAlbumIds(
sourceId: Long,
artistId: String,
): Flow<List<String>> = albumCreditAlbumIds(rawQuery(ALBUM_CREDIT_ALBUM_IDS, mapOf("sourceId" to sourceId, "artistId" to artistId)))
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
fun discs(
@@ -1083,35 +953,16 @@ interface LibraryDao {
playlistId: String,
): PagingSource<Int, PlaylistSongItem>
@Query(
"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') " +
"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 = 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",
@RawQuery(
observedEntities = [Album::class, Song::class, SongDownload::class, AlbumArtistRef::class, SongArtistRef::class],
)
fun albumsForArtistDownloaded(query: RoomRawQuery): Flow<List<Album>>
fun albumsForArtistDownloaded(
sourceId: Long,
artistId: String,
): Flow<List<Album>>
): Flow<List<Album>> =
albumsForArtistDownloaded(
rawQuery(ALBUMS_FOR_ARTIST_DOWNLOADED, mapOf("sourceId" to sourceId, "artistId" to artistId)),
)
}
@@ -5,49 +5,163 @@ 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).
internal const val ALBUMS_FILTER_BASE =
"FROM albums WHERE sourceId = ? " +
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
"AND (? = '' " +
"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(sortName), lower(?)) > 0 " +
"OR instr(lower(albumArtistSort), lower(?)) > 0))) "
// The default filters intentionally omit song_downloads: the unfiltered lists do not need the
// join, and their paging sources do not observe download writes (including progress updates).
internal const val ALBUMS_FILTER = """
FROM albums WHERE sourceId = :sourceId
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
AND (:search = ''
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), 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 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 instr(lower(sortName), lower(?)) > 0))) "
internal const val ALBUMS_FILTER_DOWNLOADED = """
FROM albums WHERE sourceId = :sourceId
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS 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')
AND (:search = ''
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), 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 PLAYLISTS_FILTER_BASE =
"FROM playlists WHERE sourceId = ? " +
"AND (? = '' " +
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
"WHERE playlist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
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 $ARTIST_CREDITED
AND (:search = ''
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM artist_search
WHERE artist_search MATCH '"' || replace(:search, '"', '""') || '"'))
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
OR instr(lower(sortName), lower(:search)) > 0)))
"""
internal fun albumsDefaultQuery(
internal const val ARTISTS_FILTER_DOWNLOADED = """
FROM artists WHERE sourceId = :sourceId
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
AND $ARTIST_CREDITED
AND EXISTS (SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl
WHERE dl.id = artists.id AND dl.status = 'Completed')
AND (:search = ''
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM artist_search
WHERE artist_search MATCH '"' || replace(:search, '"', '""') || '"'))
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
OR instr(lower(sortName), lower(:search)) > 0)))
"""
// The downloaded list counts the albums that actually have a download so the number under an artist
// matches the albums its detail screen lists.
internal val ARTIST_COLUMNS_DOWNLOADED = """
artists.sourceId, artists.id, artists.name,
(SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)) AS albumCount,
$ARTIST_COLUMNS_TAIL
"""
internal const val PLAYLISTS_FILTER = """
FROM playlists WHERE sourceId = :sourceId
AND (:search = ''
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM playlist_search
WHERE playlist_search MATCH '"' || replace(:search, '"', '""') || '"'))
OR (length(:search) < $SEARCH_MIN_LENGTH AND instr(lower(name), lower(:search)) > 0))
"""
internal const val PLAYLISTS_FILTER_DOWNLOADED = """
FROM playlists WHERE sourceId = :sourceId
AND EXISTS (SELECT 1 FROM playlist_songs ps
JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId
WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id AND sd.status = 'Completed')
AND (:search = ''
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM playlist_search
WHERE playlist_search MATCH '"' || replace(:search, '"', '""') || '"'))
OR (length(:search) < $SEARCH_MIN_LENGTH AND instr(lower(name), lower(:search)) > 0))
"""
// The downloaded list counts the songs that actually have a download so the number under a
// playlist matches the songs its detail screen lists.
internal const val PLAYLISTS_COLUMNS_DOWNLOADED = """
playlists.sourceId, playlists.id, playlists.name, playlists.comment, playlists.coverArt,
(SELECT COUNT(DISTINCT ps.songId)
FROM playlist_songs ps
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
) AS songCount,
playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared
"""
internal fun albumsPageQuery(
sourceId: Long,
starredFilter: Int,
search: String,
sort: AlbumSort,
descending: Boolean,
): RoomRawQuery {
val order = albumOrder(sort, descending)
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindAlbumFilters(sourceId, starredFilter, search)
}
}
downloaded: Boolean,
sortNameMode: SortNameMode = SortNameMode.SortName,
): RoomRawQuery =
rawQuery(
sql = "SELECT * ${if (downloaded) ALBUMS_FILTER_DOWNLOADED else ALBUMS_FILTER} ORDER BY ${albumOrder(
sort,
descending,
sortNameMode,
)}",
bindings =
mapOf(
"sourceId" to sourceId,
"starredFilter" to starredFilter,
"search" to search,
),
)
internal fun artistsPageQuery(
sourceId: Long,
starredFilter: Int,
search: String,
sort: ArtistSort,
descending: Boolean,
downloaded: Boolean,
sortNameMode: SortNameMode = SortNameMode.SortName,
): RoomRawQuery =
rawQuery(
sql =
"SELECT ${if (downloaded) ARTIST_COLUMNS_DOWNLOADED else ARTIST_COLUMNS} " +
"${if (downloaded) ARTISTS_FILTER_DOWNLOADED else ARTISTS_FILTER} " +
"ORDER BY ${artistOrder(sort, descending, sortNameMode)}",
bindings =
mapOf(
"sourceId" to sourceId,
"starredFilter" to starredFilter,
"search" to search,
),
)
internal fun playlistsPageQuery(
sourceId: Long,
search: String,
sort: PlaylistSort,
descending: Boolean,
downloaded: Boolean,
): RoomRawQuery =
rawQuery(
sql =
"SELECT ${if (downloaded) PLAYLISTS_COLUMNS_DOWNLOADED else "*"} " +
"${if (downloaded) PLAYLISTS_FILTER_DOWNLOADED else PLAYLISTS_FILTER} " +
"ORDER BY ${playlistOrder(sort, descending)}",
bindings =
mapOf(
"sourceId" to sourceId,
"search" to search,
),
)
internal fun rediscoverAlbumsQuery(
sourceId: Long,
@@ -55,93 +169,126 @@ internal fun rediscoverAlbumsQuery(
search: String,
cutoff: Long,
): RoomRawQuery =
RoomRawQuery(
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
) { stmt ->
stmt.bindAlbumFilters(sourceId, starredFilter, search)
stmt.bindLong(13, cutoff)
}
rawQuery(
sql =
"SELECT * $ALBUMS_FILTER AND played IS NOT NULL AND played < :cutoff " +
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
bindings =
mapOf(
"sourceId" to sourceId,
"starredFilter" to starredFilter,
"search" to search,
"cutoff" to cutoff,
),
)
private fun SQLiteStatement.bindAlbumFilters(
sourceId: Long,
starredFilter: Int,
search: String,
// RoomRawQuery takes a positional bind function, but SQLite gives a repeated named parameter one
// index, so the SQL can keep its names and only the first-appearance order needs binding.
internal fun rawQuery(
sql: String,
bindings: Map<String, Any?>,
): RoomRawQuery {
val values =
Regex(":(\\w+)")
.findAll(sql)
.map { it.groupValues[1] }
.distinct()
.map(bindings::getValue)
.toList()
return RoomRawQuery(sql) { statement ->
values.forEachIndexed { index, value -> statement.bind(index + 1, value) }
}
}
private fun SQLiteStatement.bind(
index: Int,
value: Any?,
) {
bindLong(1, sourceId)
bindInt(2, starredFilter)
bindInt(3, starredFilter)
bindInt(4, starredFilter)
bindText(5, search)
bindText(6, search)
bindText(7, search)
bindText(8, search)
bindText(9, search)
bindText(10, search)
bindText(11, search)
bindText(12, search)
}
internal fun artistsDefaultQuery(
sourceId: Long,
starredFilter: Int,
search: String,
sort: ArtistSort,
descending: Boolean,
): RoomRawQuery {
val order = artistOrder(sort, descending)
return RoomRawQuery("SELECT * $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindLong(1, sourceId)
stmt.bindInt(2, starredFilter)
stmt.bindInt(3, starredFilter)
stmt.bindInt(4, starredFilter)
stmt.bindText(5, search)
stmt.bindText(6, search)
stmt.bindText(7, search)
stmt.bindText(8, search)
stmt.bindText(9, search)
stmt.bindText(10, search)
when (value) {
null -> bindNull(index)
is Int -> bindLong(index, value.toLong())
is Long -> bindLong(index, value)
is String -> bindText(index, value)
else -> error("Unsupported SQL parameter: ${value::class.qualifiedName}")
}
}
internal fun playlistsDefaultQuery(
sourceId: Long,
search: String,
sort: PlaylistSort,
descending: Boolean,
): RoomRawQuery {
val order = playlistOrder(sort, descending)
return RoomRawQuery("SELECT * $PLAYLISTS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindLong(1, sourceId)
stmt.bindText(2, search)
stmt.bindText(3, search)
stmt.bindText(4, search)
stmt.bindText(5, search)
stmt.bindText(6, search)
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 -> {
@@ -579,6 +579,90 @@ val MIGRATION_32_33 =
}
}
// 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,
@@ -611,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,
@@ -645,4 +803,9 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_30_31,
MIGRATION_31_32,
MIGRATION_32_33,
MIGRATION_33_34,
MIGRATION_34_35,
MIGRATION_35_36,
MIGRATION_36_37,
MIGRATION_37_38,
)
@@ -20,17 +20,7 @@ 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 id FROM songs WHERE sourceId = :sourceId AND artistId = :artistId " +
"UNION " +
"SELECT songId FROM song_artists WHERE sourceId = :sourceId AND artistId = :artistId " +
"UNION " +
"SELECT songs.id FROM albums " +
"JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
"UNION " +
"SELECT songs.id FROM album_artists " +
"JOIN songs ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId)"
"(SELECT songId FROM ($CREDITED_SONG_ROWS) WHERE artistId = :artistId)"
private const val ARTIST_SONGS_SELECT =
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
@@ -224,14 +214,34 @@ interface QueueDao {
): List<String>
@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.id IN (:ids)",
"SELECT CASE WHEN EXISTS (SELECT 1 FROM songs WHERE sourceId = :sourceId AND albumId = :albumId AND id = :songId) " +
"THEN (SELECT COUNT(*) FROM songs ranked CROSS JOIN songs target " +
"WHERE ranked.sourceId = :sourceId AND ranked.albumId = :albumId " +
"AND target.sourceId = :sourceId AND target.albumId = :albumId AND target.id = :songId " +
"AND ((ranked.disc IS NULL AND target.disc IS NOT NULL) OR ranked.disc < target.disc OR " +
"(ranked.disc IS target.disc AND ((ranked.track IS NULL AND target.track IS NOT NULL) OR ranked.track < target.track OR " +
"(ranked.track IS target.track AND ranked.id < target.id))))) END",
)
suspend fun songsByIds(
suspend fun albumRank(
sourceId: Long,
ids: List<String>,
): List<AlbumSongItem>
albumId: String,
songId: String,
): Long?
@Query(
"SELECT CASE WHEN firstPosition IS NULL THEN NULL ELSE " +
"(SELECT COUNT(*) - 1 FROM playlist_songs " +
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId AND position <= firstPosition) END " +
"FROM (SELECT MIN(position) AS firstPosition FROM playlist_songs " +
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId AND songId = :songId)",
)
suspend fun playlistRank(
sourceId: Long,
playlistId: String,
songId: String,
): Long?
@Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
suspend fun genreLength(
@@ -253,17 +263,6 @@ interface QueueDao {
limit: Int,
): List<AlbumSongItem>
@Query(
"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.sortName COLLATE NOCASE, songs.id",
)
suspend fun genreSongIds(
sourceId: Long,
genre: String,
): List<String>
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
suspend fun downloadedLength(sourceId: Long): Long
@@ -282,12 +281,6 @@ interface QueueDao {
limit: Int,
): List<AlbumSongItem>
@Query(
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
)
suspend fun starredSongIds(sourceId: Long): List<String>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " +
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
@@ -300,12 +293,4 @@ interface QueueDao {
offset: Long,
limit: Int,
): List<AlbumSongItem>
@Query(
"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.sortName COLLATE NOCASE, songs.id",
)
suspend fun downloadedSongIds(sourceId: Long): List<String>
}
@@ -7,6 +7,7 @@ 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
@@ -27,6 +28,7 @@ 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
@@ -35,6 +37,7 @@ import com.subtracks.data.model.UpNextEntry
Source::class,
SubsonicSource::class,
Artist::class,
ArtistBio::class,
ArtistSearch::class,
Album::class,
AlbumSearch::class,
@@ -54,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class,
PendingAction::class,
],
version = 33,
version = 38,
exportSchema = true,
)
@ColumnTypeConverters(
@@ -63,6 +66,7 @@ import com.subtracks.data.model.UpNextEntry
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 }
@@ -99,17 +104,19 @@ data class DownloadedSong(
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)
@@ -59,6 +59,7 @@ data class ArtistRef(
indices = [
Index("sourceId"),
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", "sortName", "id"],
@@ -88,6 +89,9 @@ 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,
@@ -95,6 +99,24 @@ data class Artist(
@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(
tableName = "albums",
primaryKeys = ["sourceId", "id"],
@@ -110,7 +132,9 @@ data class Artist(
Index("sourceId"),
Index("sourceId", "artistId"),
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", "sortName", "id"],
@@ -167,6 +191,13 @@ data class Album(
}
}
data class ArtistAlbums(
val albumCredits: List<Album> = emptyList(),
val appearances: List<Album> = emptyList(),
) {
val count: Int get() = albumCredits.size + appearances.size
}
@Entity(
tableName = "discs",
primaryKeys = ["sourceId", "albumId", "disc"],
@@ -223,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(
@@ -567,6 +600,16 @@ class ArtistRefsConverter {
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 {
@@ -16,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 }
@@ -57,7 +59,7 @@ const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
enum class SyncMode { Off, WifiOnly, Always }
enum class ReplayGainMode { Off, Track, Album }
enum class ReplayGainMode { Off, Track, Album, Auto }
data class ArtworkSeedValue(
val cacheKey: String,
@@ -154,7 +156,7 @@ class UserPreferences(
fun replayGainMode(): Flow<ReplayGainMode> =
store.data.map { prefs ->
prefs[replayGainModeKey]?.let { stored -> ReplayGainMode.entries.firstOrNull { it.name == stored } } ?: ReplayGainMode.Track
prefs[replayGainModeKey]?.let { stored -> ReplayGainMode.entries.firstOrNull { it.name == stored } } ?: ReplayGainMode.Auto
}
suspend fun setReplayGainMode(mode: ReplayGainMode) {
@@ -182,6 +184,17 @@ class UserPreferences(
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
}
}
@@ -7,13 +7,15 @@ import androidx.paging.PagingSource
import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.albumsDefaultQuery
import com.subtracks.data.db.artistsDefaultQuery
import com.subtracks.data.db.playlistsDefaultQuery
import com.subtracks.data.db.albumsPageQuery
import com.subtracks.data.db.artistsPageQuery
import com.subtracks.data.db.playlistsPageQuery
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) }
@@ -174,69 +192,20 @@ class LibraryRepository(
search: String,
downloaded: Boolean,
): Flow<PagingData<Album>> =
pager(40, enablePlaceholders = true) {
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)
}
}
AlbumSort.Artist -> {
if (descending) {
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByArtist(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.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.Recent -> {
if (descending) {
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
}
}
}
userPreferences.sortNameMode().flatMapLatest { mode ->
pager(40, enablePlaceholders = true) {
val dao = db.libraryDao()
val query =
albumsPageQuery(
sourceId = sourceId,
starredFilter = starred.ordinal,
search = search,
sort = sort,
descending = descending,
downloaded = downloaded,
sortNameMode = mode,
)
if (downloaded) dao.downloadedAlbumsPaging(query) else dao.albumsPaging(query)
}
}
@@ -248,53 +217,20 @@ 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)
}
}
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.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)
}
}
}
userPreferences.sortNameMode().flatMapLatest { mode ->
pager(60) {
val dao = db.libraryDao()
val query =
artistsPageQuery(
sourceId = sourceId,
starredFilter = starred.ordinal,
search = search,
sort = sort,
descending = descending,
downloaded = downloaded,
sortNameMode = mode,
)
if (downloaded) dao.downloadedArtistsPaging(query) else dao.artistsPaging(query)
}
}
@@ -307,36 +243,15 @@ class LibraryRepository(
): Flow<PagingData<Playlist>> =
pager(40) {
val dao = db.libraryDao()
if (!downloaded) {
dao.playlistsPaging(playlistsDefaultQuery(sourceId, search, sort, descending))
} else {
val downloadedFilter = 1
when (sort) {
PlaylistSort.Name -> {
if (descending) {
dao.playlistsByNameReversed(sourceId, search, downloadedFilter)
} else {
dao.playlistsByName(sourceId, search, downloadedFilter)
}
}
PlaylistSort.Added -> {
if (descending) {
dao.playlistsByAddedReversed(sourceId, search, downloadedFilter)
} else {
dao.playlistsByAdded(sourceId, search, downloadedFilter)
}
}
PlaylistSort.Updated -> {
if (descending) {
dao.playlistsByUpdatedReversed(sourceId, search, downloadedFilter)
} else {
dao.playlistsByUpdated(sourceId, search, downloadedFilter)
}
}
}
}
val query =
playlistsPageQuery(
sourceId = sourceId,
search = search,
sort = sort,
descending = descending,
downloaded = downloaded,
)
if (downloaded) dao.downloadedPlaylistsPaging(query) else dao.playlistsPaging(query)
}
fun album(
@@ -354,6 +269,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 +321,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,
@@ -403,10 +360,7 @@ class LibraryRepository(
sourceId: Long,
albumId: String,
songId: String,
): Long {
val index = db.queueDao().albumSongIds(sourceId, albumId).indexOf(songId)
return if (index < 0) 0L else index.toLong()
}
): Long = db.queueDao().albumRank(sourceId, albumId, songId) ?: 0L
fun playlist(
sourceId: Long,
@@ -520,4 +474,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(
@@ -207,6 +209,202 @@ data class Reanchored(
val changed: Boolean,
)
private interface QueueKindResolver {
fun supportsKeyset(key: OrderKey): Boolean = false
suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
): Long
suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
): List<SongItem>
suspend fun rowsFrom(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
): List<SongItem>? = null
suspend fun rowsBefore(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
): List<SongItem>? = null
suspend fun rank(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
songId: String,
): Long? = null
}
private val queueKindResolvers =
mapOf<QueueKind, QueueKindResolver>(
QueueKind.Song to
object : QueueKindResolver {
override suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
) = dao.songLength(entry.sourceId, entry.refId)
override suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
) = dao.song(entry.sourceId, entry.refId, offset, limit)
override suspend fun rank(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
songId: String,
) = if (entry.refId == songId && length(dao, entry) > 0) 0L else null
},
QueueKind.Playlist to
object : QueueKindResolver {
override fun supportsKeyset(key: OrderKey) = key.position != null
override suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
) = dao.playlistLength(entry.sourceId, entry.refId)
override suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
) = dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
override suspend fun rowsFrom(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
) = dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
override suspend fun rowsBefore(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
) = dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
override suspend fun rank(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
songId: String,
) = dao.playlistRank(entry.sourceId, entry.refId, songId)
},
QueueKind.Album to
object : QueueKindResolver {
override fun supportsKeyset(key: OrderKey) = key.disc != null && key.track != null
override suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
) = dao.albumLength(entry.sourceId, entry.refId)
override suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
) = dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
override suspend fun rowsFrom(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
) = dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
override suspend fun rowsBefore(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
) = dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
override suspend fun rank(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
songId: String,
) = dao.albumRank(entry.sourceId, entry.refId, songId)
},
QueueKind.Artist to
object : QueueKindResolver {
override suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
) = dao.artistLength(entry.sourceId, entry.refId)
override suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
) = dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
},
QueueKind.Genre to
object : QueueKindResolver {
override suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
) = dao.genreLength(entry.sourceId, entry.refId)
override suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
) = dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
},
QueueKind.Downloaded to
object : QueueKindResolver {
override suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
) = dao.downloadedLength(entry.sourceId)
override suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
) = dao.downloadedSongs(entry.sourceId, offset, limit)
},
QueueKind.Starred to
object : QueueKindResolver {
override suspend fun length(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
) = dao.starredLength(entry.sourceId)
override suspend fun rows(
dao: com.subtracks.data.db.QueueDao,
entry: QueueEntry,
offset: Long,
limit: Int,
) = dao.starredSongs(entry.sourceId, offset, limit)
},
)
private fun resolverFor(kind: QueueKind): QueueKindResolver = queueKindResolvers.getValue(kind)
class QueueRepository(
private val db: SubtracksDatabase,
) {
@@ -214,12 +412,6 @@ class QueueRepository(
private val queueVersion = AtomicLong()
@Volatile
private var cachedFlatIds: List<String>? = null
@Volatile
private var cachedFlatVersion = -1L
@Volatile
private var shuffleRemoved: ShuffleState? = null
@@ -233,6 +425,9 @@ class QueueRepository(
internal var offsetSeeks = 0L
private set
internal var resolvedRowCount = 0L
private set
private val cursorMutex = Mutex()
fun playlistEntry(
@@ -295,8 +490,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)
@@ -386,7 +581,6 @@ class QueueRepository(
}
fun invalidateLibraryCache() {
cachedFlatIds = null
lengthCache.clear()
keyCache.clear()
shuffleRemoved = null
@@ -407,8 +601,8 @@ class QueueRepository(
snapshot: QueueSnapshot,
songId: String,
): Long? {
val upIndex = idsFor(snapshot.upNext).indexOf(songId)
if (upIndex >= 0) return snapshot.anchorPlay + 1 + upIndex
val upIndex = flatIndexOf(snapshot.upNext, songId)
if (upIndex != null) return snapshot.anchorPlay + 1 + upIndex
return combinedContextIndexOf(snapshot, songId)
}
@@ -416,9 +610,8 @@ class QueueRepository(
snapshot: QueueSnapshot,
songId: String,
): Long? {
val flat = flatIds(snapshot).indexOf(songId)
if (flat < 0) return null
return snapshot.playContext(flat.toLong())?.let { snapshot.combined(it) }
val flat = flatIndexOf(snapshot.entries, songId) ?: return null
return snapshot.playContext(flat)?.let { snapshot.combined(it) }
}
suspend fun range(
@@ -581,9 +774,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 +790,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)
@@ -631,7 +824,6 @@ class QueueRepository(
}
}
queueVersion.incrementAndGet()
cachedFlatIds = null
}
private suspend fun writeUpNext(entries: List<QueueEntry>) {
@@ -648,34 +840,40 @@ class QueueRepository(
queueVersion.incrementAndGet()
}
private suspend fun flatIds(snapshot: QueueSnapshot): List<String> {
val cached = cachedFlatIds
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached
val ids = idsFor(snapshot.entries)
cachedFlatIds = ids
cachedFlatVersion = snapshot.version
return ids
private suspend fun flatIndexOf(
entries: List<ResolvedQueueEntry>,
songId: String,
): Long? {
var start = 0L
for (resolved in entries) {
val entry = resolved.entry
val rank = resolverFor(entry.kind).rank(dao, entry, songId)
if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) {
return start + rank - entry.offset
}
val fallback = fallbackRank(entry, resolved.length, songId)
if (fallback != null) return start + fallback - entry.offset
start += resolved.length
}
return null
}
private suspend fun idsFor(resolved: List<ResolvedQueueEntry>): List<String> {
val ids = ArrayList<String>()
for (entry in resolved) {
val ref = entry.entry
val entryIds =
when (ref.kind) {
QueueKind.Song -> listOf(ref.refId)
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId)
QueueKind.Genre -> dao.genreSongIds(ref.sourceId, ref.refId)
QueueKind.Downloaded -> dao.downloadedSongIds(ref.sourceId)
QueueKind.Starred -> dao.starredSongIds(ref.sourceId)
}
val from = ref.offset.toInt().coerceIn(0, entryIds.size)
val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size)
ids.addAll(entryIds.subList(from, to))
private suspend fun fallbackRank(
entry: QueueEntry,
length: Long,
songId: String,
): Long? {
var offset = entry.offset
val end = entry.offset + length
while (offset < end) {
val limit = minOf(QUEUE_CHUNK.toLong(), end - offset).toInt()
val batch = rows(entry, offset, limit)
val index = batch.indexOfFirst { it.song.id == songId }
if (index >= 0) return offset + index
if (batch.size < limit) return null
offset += batch.size
}
return ids
return null
}
private suspend fun contextPlayRange(
@@ -686,21 +884,11 @@ class QueueRepository(
if (first > last) return emptyList()
val items = mutableListOf<QueueWindowItem>()
if (snapshot.shuffled) {
val sourceId =
snapshot.entries
.firstOrNull()
?.entry
?.sourceId ?: return emptyList()
val ids = flatIds(snapshot)
val requested = mutableListOf<Pair<Long, String>>()
for (play in first..last) {
val flat = snapshot.flatContext(play) ?: continue
val id = ids.getOrNull(flat.toInt()) ?: continue
requested += play to id
val located = snapshot.locateContext(play) ?: continue
val item = rows(located.first, located.first.offset + located.second, 1).firstOrNull() ?: continue
items += QueueWindowItem(snapshot.combined(play), item, playlistPosition = item.playlistPosition)
}
if (requested.isEmpty()) return emptyList()
val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id }
requested.forEach { (play, id) -> byId[id]?.let { items += QueueWindowItem(snapshot.combined(play), it) } }
return items
}
var start = 0L
@@ -755,27 +943,20 @@ class QueueRepository(
): List<SongItem> {
if (entry.kind == QueueKind.Song) {
offsetSeeks++
return dao.song(entry.sourceId, entry.refId, offset, limit)
return resolverFor(entry.kind).rows(dao, entry, offset, limit).also { resolvedRowCount += it.size }
}
val ref = QueueRef(entry.kind, entry.sourceId, entry.refId)
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
if (anchored != null) {
keysetSeeks++
if (anchored.isNotEmpty()) remember(ref, offset, anchored)
resolvedRowCount += anchored.size
return anchored
}
offsetSeeks++
val fresh =
when (entry.kind) {
QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Artist -> dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Genre -> dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Downloaded -> dao.downloadedSongs(entry.sourceId, offset, limit)
QueueKind.Starred -> dao.starredSongs(entry.sourceId, offset, limit)
QueueKind.Song -> emptyList()
}
val fresh = resolverFor(entry.kind).rows(dao, entry, offset, limit)
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
resolvedRowCount += fresh.size
return fresh
}
@@ -808,7 +989,7 @@ class QueueRepository(
limit: Int,
cursor: KeyCursor,
): List<SongItem>? {
if (!cursor.key.seekable(entry.kind)) return null
if (!resolverFor(entry.kind).supportsKeyset(cursor.key)) return null
val from = cursor.ordinal
// A backward seek from a far cursor is not cheaper than the offset query: the album
// predicate is not sargable (it has to include NULL disc/track), so it scans the partition
@@ -834,43 +1015,14 @@ class QueueRepository(
key: OrderKey,
skip: Long,
limit: Int,
): List<SongItem> =
when (entry.kind) {
QueueKind.Playlist -> dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
QueueKind.Album -> dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
QueueKind.Artist -> emptyList()
QueueKind.Genre -> emptyList()
QueueKind.Downloaded -> emptyList()
QueueKind.Starred -> emptyList()
QueueKind.Song -> emptyList()
}
): List<SongItem> = resolverFor(entry.kind).rowsFrom(dao, entry, key, skip, limit) ?: emptyList()
private suspend fun rowsBefore(
entry: QueueEntry,
key: OrderKey,
skip: Long,
limit: Int,
): List<SongItem> =
when (entry.kind) {
QueueKind.Playlist -> dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
QueueKind.Album -> dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
QueueKind.Artist -> emptyList()
QueueKind.Genre -> emptyList()
QueueKind.Downloaded -> emptyList()
QueueKind.Starred -> emptyList()
QueueKind.Song -> emptyList()
}
private fun OrderKey.seekable(kind: QueueKind): Boolean =
when (kind) {
QueueKind.Playlist -> position != null
QueueKind.Album -> disc != null && track != null
QueueKind.Artist -> false
QueueKind.Genre -> false
QueueKind.Downloaded -> false
QueueKind.Starred -> false
QueueKind.Song -> false
}
): List<SongItem> = resolverFor(entry.kind).rowsBefore(dao, entry, key, skip, limit) ?: emptyList()
private suspend fun lengthOf(
entry: QueueEntry,
@@ -895,40 +1047,23 @@ class QueueRepository(
)
private suspend fun QueueEntry.resolvedLength(): Long {
val total =
when (kind) {
QueueKind.Song -> {
dao.songLength(sourceId, refId)
}
QueueKind.Playlist -> {
dao.playlistLength(sourceId, refId)
}
QueueKind.Album -> {
dao.albumLength(sourceId, refId)
}
QueueKind.Artist -> {
dao.artistLength(sourceId, refId)
}
QueueKind.Genre -> {
dao.genreLength(sourceId, refId)
}
QueueKind.Downloaded -> {
dao.downloadedLength(sourceId)
}
QueueKind.Starred -> {
dao.starredLength(sourceId)
}
}
val total = resolverFor(kind).length(dao, this)
val available = (total - offset).coerceAtLeast(0)
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,
@@ -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)))
}
@@ -85,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",
@@ -365,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
}
}
@@ -32,6 +32,7 @@ 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"),
)
@@ -128,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(
@@ -165,6 +168,12 @@ object SubsonicXml {
parse(input, handler)
return handler.found
}
internal fun artistBiography(input: InputStream): String? {
val handler = ArtistBiographyHandler()
parse(input, handler)
return handler.biography
}
}
internal class AlbumDraft(
@@ -358,6 +367,8 @@ 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)
@@ -421,6 +432,40 @@ 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,
@@ -31,23 +31,17 @@ class SyncService(
private var playlistSongsSynced = 0
private var pruned = 0
private var creditsChanged = false
suspend fun sync(): SyncSummary {
val sources = db.sourcesDao()
val wasStale = sources.creditsStale(source.id) ?: true
creditsChanged = false
val artistsBefore = dbTotalChanges()
syncArtists()
val artistsChanged = dbTotalChanges() != artistsBefore
syncAlbums()
syncSongs()
val artistsChanged = syncArtists()
val albumsChanged = syncAlbums()
val songsChanged = syncSongs()
optimize()
if (creditsChanged || artistsChanged || wasStale) {
recomputeArtistSongCounts()
if (artistsChanged || albumsChanged || songsChanged || wasStale) {
recomputeAggregates()
sources.setCreditsStale(source.id, false)
}
aggregatePlayData()
syncPlaylists()
syncPlaylistSongs()
return SyncSummary(
@@ -60,29 +54,35 @@ 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", primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.columns() } ||
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
@@ -95,19 +95,20 @@ class SyncService(
{ library.deleteAlbums(source.id, it) },
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
)
if (pruned > prunedBefore) creditsChanged = 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
}
@@ -119,10 +120,10 @@ class SyncService(
{ library.deleteSongs(source.id, it) },
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
)
if (pruned > prunedBefore) creditsChanged = 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 ->
@@ -132,12 +133,11 @@ class SyncService(
batch.flatMap { song ->
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
}
db.useWriterConnection { connection ->
return db.useWriterConnection { connection ->
connection.immediateTransaction {
val albumChanged = connection.albumIdsChanged(batch)
if (albumChanged) creditsChanged = true
val before = connection.totalChanges()
val resolved = connection.resolveSongSortNames(batch)
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
connection.upsertChanged("songs", primaryKey, resolved) { it.columns() }
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
batch.forEach { song ->
statement.bind(1, song.sourceId)
@@ -148,8 +148,7 @@ class SyncService(
statement.clearBindings()
}
}
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
val artistRowsBefore = connection.totalChanges()
connection.upsertChanged("song_genres", songGenreKey, genres) { it.columns() }
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
batch.forEach { song ->
statement.bind(1, song.sourceId)
@@ -160,22 +159,16 @@ class SyncService(
statement.clearBindings()
}
}
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
val artistRowsChanged = connection.totalChanges() != artistRowsBefore
if (albumChanged || artistRowsChanged) {
connection.markCreditsStale(batch.first().sourceId)
creditsChanged = true
connection.upsertChanged("song_artists", songArtistKey, artists) { it.columns() }
(connection.totalChanges() != before).also { changed ->
if (changed) connection.markCreditsStale(batch.first().sourceId)
}
}
}
}
private suspend fun aggregatePlayData() {
db.libraryDao().recomputePlayData(source.id)
}
private suspend fun recomputeArtistSongCounts() {
db.libraryDao().recomputeArtistSongCounts(source.id)
private suspend fun recomputeAggregates() {
db.libraryDao().recomputeAggregates(source.id)
}
private suspend fun PooledConnection.totalChanges(): Long {
@@ -187,8 +180,6 @@ class SyncService(
return value
}
private suspend fun dbTotalChanges(): Long = db.useWriterConnection { it.totalChanges() }
private suspend fun PooledConnection.markCreditsStale(sourceId: Long) {
usePrepared("UPDATE sources SET creditsStale = 1 WHERE id = ? AND creditsStale = 0") { statement ->
statement.bindLong(1, sourceId)
@@ -196,28 +187,6 @@ class SyncService(
}
}
private suspend fun PooledConnection.albumIdsChanged(batch: List<Song>): Boolean {
val expected = batch.associate { it.id to it.albumId }.toMutableMap()
if (expected.isEmpty()) return false
var changed = false
batch
.map { it.sourceId to it.id }
.chunked(SQL_VARIABLES)
.forEach { chunk ->
val placeholders = chunk.joinToString(",") { "?" }
usePrepared("SELECT id, albumId FROM songs WHERE sourceId = ? AND id IN ($placeholders)") { statement ->
statement.bindLong(1, chunk.first().first)
chunk.forEachIndexed { index, (_, id) -> statement.bindText(index + 2, id) }
while (statement.step()) {
val id = statement.getText(0)
val albumId = if (statement.isNull(1)) null else statement.getText(1)
if (expected.remove(id) != albumId) changed = true
}
}
}
return changed || expected.isNotEmpty()
}
private suspend fun PooledConnection.resolveSongSortNames(batch: List<Song>): List<Song> {
if (batch.isEmpty()) return batch
val sortNames =
@@ -287,7 +256,7 @@ class SyncService(
val seen = HashedIds()
source.playlists().collect { batch ->
if (batch.isEmpty()) return@collect
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
write("playlists", primaryKey, batch) { it.columns() }
batch.forEach { seen.add(idHash(it.id)) }
playlistsSynced += batch.size
}
@@ -300,7 +269,7 @@ class SyncService(
val counts = HashMap<String, Long>()
source.playlistSongs(playlistIds).collect { batch ->
if (batch.isEmpty()) return@collect
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
write("playlist_songs", playlistSongKey, batch) { it.columns() }
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
playlistSongsSynced += batch.size
}
@@ -311,32 +280,36 @@ class SyncService(
// One commit per batch, so Room notifies paged observers once per batch rather than per row.
private suspend fun <T> write(
table: String,
columns: List<String>,
key: List<String>,
rows: List<T>,
values: (T) -> List<Any?>,
) {
preserved: Set<String> = emptySet(),
markStale: Boolean = false,
columns: (T) -> List<Pair<String, Any?>>,
): Boolean =
db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.upsertChanged(table, columns, key, rows, values)
val before = connection.totalChanges()
connection.upsertChanged(table, key, rows, preserved, columns)
(connection.totalChanges() != before).also { changed ->
if (changed && markStale) connection.markCreditsStale(source.id)
}
}
}
}
private suspend fun writeAlbumsAndDiscs(
albums: List<Album>,
discs: List<Disc>,
) {
): Boolean {
val artists =
albums.flatMap { album ->
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
}
db.useWriterConnection { connection ->
return db.useWriterConnection { connection ->
connection.immediateTransaction {
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() }
val artistRowsBefore = connection.totalChanges()
connection.upsertChanged("albums", primaryKey, resolved) { it.columns() }
connection.upsertChanged("discs", discKey, discs) { it.columns() }
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
albums.forEach { album ->
statement.bind(1, album.sourceId)
@@ -347,10 +320,9 @@ class SyncService(
statement.clearBindings()
}
}
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
if (connection.totalChanges() != artistRowsBefore) {
connection.markCreditsStale(albums.first().sourceId)
creditsChanged = true
connection.upsertChanged("album_artists", albumArtistKey, artists) { it.columns() }
(connection.totalChanges() != before).also { changed ->
if (changed) connection.markCreditsStale(albums.first().sourceId)
}
}
}
@@ -465,57 +437,8 @@ private fun discHash(
private val primaryKey = listOf("sourceId", "id")
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "sortName")
private val albumColumns =
listOf(
"sourceId",
"id",
"artistId",
"name",
"albumArtist",
"created",
"coverArt",
"genre",
"year",
"starred",
"songCount",
"artists",
"sortName",
"albumArtistSort",
)
private val discKey = listOf("sourceId", "albumId", "disc")
private val discColumns = listOf("sourceId", "albumId", "disc", "title")
private val songColumns =
listOf(
"sourceId",
"id",
"albumId",
"artistId",
"title",
"album",
"artist",
"duration",
"track",
"disc",
"starred",
"created",
"playCount",
"played",
"trackGain",
"albumGain",
"trackPeak",
"albumPeak",
"artists",
"sortName",
"artistSort",
)
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
private fun Song.genreNames(): List<String> =
genres
.flatMap { it.genreParts() }
@@ -526,71 +449,93 @@ private fun Song.genreNames(): List<String> =
// Navidrome's default genre tag split (resources/mappings.yaml): semicolon, slash, comma.
private fun String.genreParts(): List<String> = split(';', '/', ',').map { it.trim() }.filter { it.isNotEmpty() }
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, sortName)
private fun Album.values() =
listOf<Any?>(
sourceId,
id,
artistId,
name,
albumArtist,
created,
coverArt,
genre,
year,
starred,
songCount,
ArtistRefsCodec.encode(artistRefs),
sortName,
albumArtistSort,
private fun Artist.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"id" to id,
"name" to name,
"albumCount" to albumCount,
"starred" to starred,
"coverArt" to coverArt,
"artistImageUrl" to artistImageUrl,
"sortName" to sortName,
)
private fun AlbumArtistRef.values() = listOf<Any?>(sourceId, albumId, position, artistId, name)
private fun Album.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"id" to id,
"artistId" to artistId,
"name" to name,
"albumArtist" to albumArtist,
"created" to created,
"coverArt" to coverArt,
"genre" to genre,
"year" to year,
"starred" to starred,
"songCount" to songCount,
"artists" to ArtistRefsCodec.encode(artistRefs),
"sortName" to sortName,
"albumArtistSort" to albumArtistSort,
)
private fun SongArtistRef.values() = listOf<Any?>(sourceId, songId, position, artistId, name)
private fun AlbumArtistRef.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"albumId" to albumId,
"position" to position,
"artistId" to artistId,
"name" to name,
)
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
private fun SongArtistRef.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"songId" to songId,
"position" to position,
"artistId" to artistId,
"name" to name,
)
private fun Song.values() =
listOf<Any?>(
sourceId,
id,
albumId,
artistId,
title,
album,
artist,
duration,
track,
disc,
starred,
created,
playCount,
played,
trackGain,
albumGain,
trackPeak,
albumPeak,
ArtistRefsCodec.encode(artistRefs),
sortName,
artistSort,
private fun Disc.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"albumId" to albumId,
"disc" to disc,
"title" to title,
)
private fun Song.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"id" to id,
"albumId" to albumId,
"artistId" to artistId,
"title" to title,
"album" to album,
"artist" to artist,
"duration" to duration,
"track" to track,
"disc" to disc,
"starred" to starred,
"created" to created,
"playCount" to playCount,
"played" to played,
"trackGain" to trackGain,
"albumGain" to albumGain,
"trackPeak" to trackPeak,
"albumPeak" to albumPeak,
"artists" to ArtistRefsCodec.encode(artistRefs),
"sortName" to sortName,
"artistSort" to artistSort,
)
private fun List<ArtistRef>.positions(): List<Pair<Long, ArtistRef>> =
@@ -602,31 +547,56 @@ private fun Album.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.po
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
private fun SongGenre.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"songId" to songId,
"position" to position,
"genre" to genre,
)
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
private fun Playlist.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"id" to id,
"name" to name,
"comment" to comment,
"coverArt" to coverArt,
"songCount" to songCount,
"created" to created,
"changed" to changed,
"duration" to duration,
"owner" to owner,
"shared" to shared,
)
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
private fun PlaylistSong.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"playlistId" to playlistId,
"songId" to songId,
"position" to position,
)
private suspend fun <T> PooledConnection.upsertChanged(
table: String,
columns: List<String>,
key: List<String>,
rows: List<T>,
values: (T) -> List<Any?>,
preserved: Set<String> = emptySet(),
columns: (T) -> List<Pair<String, Any?>>,
) {
if (rows.isEmpty()) return
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
val updates = columns.filterNot { it in key }
val columnNames = columns(rows.first()).map { it.first }
val updates = columnNames.filterNot { it in key || it in preserved }
val sql =
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
"INSERT INTO $table (${columnNames.joinToString()}) VALUES (${columnNames.joinToString { "?" }}) " +
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
updates.joinToString { "$it = excluded.$it" } +
" WHERE " +
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" }
usePrepared(sql) { statement ->
rows.forEach { row ->
values(row).forEachIndexed { index, value -> statement.bind(index + 1, value) }
columns(row).forEachIndexed { index, (_, value) -> statement.bind(index + 1, value) }
statement.step()
statement.reset()
statement.clearBindings()
@@ -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 {
@@ -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,6 +231,7 @@ 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
@@ -238,12 +241,20 @@ class Media3PlayerHandle(
)
}
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()
@@ -255,6 +266,8 @@ internal fun MediaItem?.replayGain(): ReplayGain {
)
}
internal fun MediaItem?.albumContext(): Boolean = this?.mediaMetadata?.extras?.getBoolean(EXTRA_ALBUM_CONTEXT) ?: false
private fun Bundle.putGain(
key: String,
value: Float?,
@@ -283,11 +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
@@ -35,6 +36,8 @@ data class QueueItem(
val coverArtId: String?,
val durationMs: Long? = null,
val replayGain: ReplayGain = ReplayGain(),
val albumContext: Boolean = false,
val artists: List<ArtistRef> = emptyList(),
)
data class QueueContext(
@@ -393,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
@@ -545,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 {
@@ -944,13 +947,13 @@ class PlaybackController(
if (!isOffline) {
return nextPosition(snapshot, at)
?.takeIf { it != at }
?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
?.let { queueItemAt(snapshot, it) }
}
downloads.awaitLoaded()
val target =
downloadedAtOrAfter(snapshot, at + 1)
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
return target?.takeIf { it != at }?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
}
suspend fun previousItem(): QueueItem? {
@@ -967,13 +970,13 @@ class PlaybackController(
if (!isOffline) {
return previousPosition(snapshot, at)
?.takeIf { it != at }
?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
?.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?.takeIf { it != at }?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
}
private fun play(
@@ -1132,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()
@@ -1172,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
}
}
@@ -1287,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) {
@@ -1296,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) {
@@ -1315,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
@@ -1328,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
@@ -1338,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
}
@@ -1481,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
@@ -1,14 +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,
)
@@ -29,17 +29,20 @@ internal fun Song.replayGain(): ReplayGain =
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) =
when (settings.mode) {
ReplayGainMode.Off -> return 1f
ReplayGainMode.Track -> (gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
ReplayGainMode.Album -> (gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
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
@@ -7,11 +7,13 @@ internal class ReplayGainController(
private val apply: (Float) -> Unit,
) {
private var track = ReplayGain()
private var settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
private var albumContext = false
private var settings = ReplayGainSettings(ReplayGainMode.Auto, 0f)
@Synchronized
fun applyItem(item: MediaItem) {
track = item.replayGain()
albumContext = item.albumContext()
applyGain()
}
@@ -22,6 +24,6 @@ internal class ReplayGainController(
}
private fun applyGain() {
apply(replayGainLinearGain(track, settings))
apply(replayGainLinearGain(track, settings, albumContext))
}
}
+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)
}
}
@@ -20,7 +20,6 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
@@ -28,7 +27,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.subtracks.R
import com.subtracks.data.model.ArtistRef
import kotlinx.coroutines.launch
const val ARTIST_CHOOSER_TAG = "artistChooser"
@@ -40,7 +38,6 @@ fun ArtistChooserSheet(
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -66,13 +63,7 @@ fun ArtistChooserSheet(
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 {
scope.launch {
sheetState.hide()
onArtistClick(artist)
}
},
modifier = Modifier.clickable { onArtistClick(artist) },
)
}
}
@@ -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(),
)
}
@@ -6,10 +6,21 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.subtracks.R
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
data class PendingDownloadDelete(
val name: String,
@@ -18,6 +29,70 @@ data class PendingDownloadDelete(
val bytes: Long,
)
class PendingDownloadDeleteController internal constructor(
private val scope: CoroutineScope,
private val downloadedBytes: State<suspend (DownloadList, String) -> Long>,
) {
var pending by mutableStateOf<PendingDownloadDelete?>(null)
private set
fun onAction(
target: MenuTarget,
action: BulkDownloadAction,
onAction: (DownloadList, String, BulkDownloadAction) -> Unit,
) {
val (list, refId) = target.bulkRef() ?: return
onAction(target.title, list, refId, action, onAction)
}
fun onAction(
name: String,
list: DownloadList,
refId: String,
action: BulkDownloadAction,
onAction: (DownloadList, String, BulkDownloadAction) -> Unit,
) {
if (action == BulkDownloadAction.Delete) {
scope.launch {
pending = PendingDownloadDelete(name, list, refId, downloadedBytes.value(list, refId))
}
} else {
onAction(list, refId, action)
}
}
fun dismiss() {
pending = null
}
}
@Composable
fun rememberPendingDownloadDelete(
dismissals: Flow<Unit>,
downloadedBytes: suspend (DownloadList, String) -> Long,
): PendingDownloadDeleteController {
val scope = rememberCoroutineScope()
val currentDownloadedBytes = rememberUpdatedState(downloadedBytes)
val controller = remember(scope) { PendingDownloadDeleteController(scope, currentDownloadedBytes) }
DismissOnRequest(dismissals, controller::dismiss)
return controller
}
@Composable
fun PendingDownloadDeleteConfirmation(
controller: PendingDownloadDeleteController,
onConfirm: (PendingDownloadDelete) -> Unit,
) {
controller.pending?.let { pending ->
DeleteDownloadsDialog(
name = pending.name,
bytes = pending.bytes,
onConfirm = { onConfirm(pending) },
onDismiss = controller::dismiss,
)
}
}
@Composable
fun DeleteDownloadsDialog(
name: String,
@@ -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,
@@ -291,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))
@@ -15,6 +15,7 @@ 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
@@ -50,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
@@ -80,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(
@@ -197,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()
@@ -210,6 +213,11 @@ fun ItemContextMenu(
}
}
fun replaceWith(after: () -> Unit) {
onDismiss()
after()
}
val queueRef = target.queueRef()
@Composable
@@ -256,18 +264,9 @@ fun ItemContextMenu(
}
}
}
target.song.artistRefs.forEach { artist ->
actions.viewArtist?.let { view ->
MenuItem(
Icons.Rounded.Person,
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artist.id) }
}
}
}
ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
onInfo?.let { info ->
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
}
}
@@ -279,15 +278,9 @@ fun ItemContextMenu(
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
onDismiss = { dismiss() },
)
target.album.artistRefs.forEach { artist ->
actions.viewArtist?.let { view ->
MenuItem(
Icons.Rounded.Person,
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artist.id) }
}
}
ArtistItems(target.album.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
onInfo?.let { info ->
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
}
}
@@ -298,11 +291,17 @@ fun ItemContextMenu(
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) } }
}
}
}
Spacer(Modifier.height(16.dp))
@@ -342,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?,
@@ -439,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),
)
@@ -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) {
@@ -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)
}
@@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -30,6 +29,7 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Disc
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
@@ -39,16 +39,16 @@ 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
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
import com.subtracks.ui.components.rememberPendingDownloadDelete
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -67,28 +67,23 @@ 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) }
val dismissals = contextMenuHost?.dismissals ?: emptyFlow()
val pendingDelete = rememberPendingDownloadDelete(dismissals) { _, _ -> viewModel.downloadedBytes() }
var showArtistChooser by remember { mutableStateOf(false) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) {
pendingDelete = null
showArtistChooser = false
}
DismissOnRequest(dismissals) { showArtistChooser = false }
val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
if (action == BulkDownloadAction.Delete) {
scope.launch { pendingDelete = viewModel.downloadedBytes() }
} else {
viewModel.onDownloadAction(action)
pendingDelete.onAction(album?.name.orEmpty(), DownloadList.Album, albumId, action) { _, _, requested ->
viewModel.onDownloadAction(requested)
}
}
val actions =
@@ -141,13 +136,8 @@ fun AlbumDetailRoute(
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
)
pendingDelete?.let { bytes ->
DeleteDownloadsDialog(
name = album?.name.orEmpty(),
bytes = bytes,
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null },
)
PendingDownloadDeleteConfirmation(pendingDelete) {
viewModel.onDownloadAction(BulkDownloadAction.Delete)
}
if (showArtistChooser) {
@@ -215,6 +205,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(
@@ -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()
@@ -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
@@ -48,53 +52,70 @@ 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.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
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.PendingDownloadDelete
import com.subtracks.ui.components.bulkRef
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
import com.subtracks.ui.components.rememberPendingDownloadDelete
import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.theme.ArtworkColors
@@ -106,10 +127,15 @@ 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.launch
import kotlinx.coroutines.flow.map
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
@@ -120,6 +146,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,
@@ -139,11 +171,14 @@ 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"
internal fun isAlbumArtist(
albums: List<Album>,
artistId: String,
): Boolean = albums.any { album -> album.artistId == artistId || album.artistRefs.any { it.id == artistId } }
private data class BioSnapshot(
val known: Boolean,
val present: Boolean,
val biography: String?,
)
@Composable
fun ArtistDetailRoute(
@@ -157,46 +192,51 @@ 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 = null)
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)
val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
val actions =
ItemActions(
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
bulkDownload = { target, action ->
target.bulkRef()?.let { (list, refId) ->
if (action == BulkDownloadAction.Delete) {
scope.launch {
pendingDelete =
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
}
} else {
viewModel.onDownloadAction(list, refId, action)
}
}
},
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
setStar = setStar,
viewAlbum = onViewAlbum,
)
ArtistDetailScreen(
artist = artist,
artistName = artistName.orEmpty(),
isAlbumArtist = albums?.let { isAlbumArtist(it, artistId) },
albums = albums.orEmpty(),
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,
@@ -206,16 +246,13 @@ 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,
)
pendingDelete?.let { pending ->
DeleteDownloadsDialog(
name = pending.name,
bytes = pending.bytes,
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null },
)
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
}
}
@@ -225,11 +262,16 @@ fun ArtistDetailScreen(
artist: Artist?,
modifier: Modifier = Modifier,
artistName: String = "",
isAlbumArtist: Boolean? = null,
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,
@@ -239,9 +281,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 =
@@ -252,9 +296,24 @@ 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 == true) R.string.artist_type_album_artist else R.string.artist_type_artist)
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 =
@@ -273,8 +332,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 {
@@ -306,7 +365,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,
@@ -328,11 +389,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) {
@@ -384,100 +443,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 = if (albumsLoaded) artistType else "",
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 =
if (albumsLoaded) {
pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
} else {
""
},
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 },
)
}
}
@@ -523,6 +553,434 @@ 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,
@@ -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
@@ -23,8 +25,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -38,23 +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) }
.map<List<Album>, List<Album>?> { it }
.onStart { emit(null) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
val downloadStatus: StateFlow<ListDownloadStatus> =
sourceId
@@ -66,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,
@@ -88,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,
@@ -123,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
@@ -64,13 +68,11 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
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
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
@@ -92,12 +94,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,14 +123,14 @@ 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
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.PendingDownloadDelete
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
import com.subtracks.ui.components.SearchField
import com.subtracks.ui.components.bulkRef
import com.subtracks.ui.components.rememberPendingDownloadDelete
import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.home.HomeRoute
import com.subtracks.ui.home.HomeSection
@@ -136,7 +141,6 @@ import com.subtracks.ui.theme.playerSurfaceColor
import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import kotlin.math.abs
@@ -271,25 +275,13 @@ fun LibraryRoute(
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
val downloads = downloadsState?.value ?: emptyMap()
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
val focusManager = LocalFocusManager.current
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
val itemActions =
ItemActions(
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
bulkDownload = { target, action ->
target.bulkRef()?.let { (list, refId) ->
if (action == BulkDownloadAction.Delete) {
scope.launch {
pendingDelete =
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
}
} else {
viewModel.onDownloadAction(list, refId, action)
}
}
},
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
setStar = setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
@@ -305,13 +297,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,
@@ -365,13 +361,8 @@ fun LibraryRoute(
},
)
pendingDelete?.let { pending ->
DeleteDownloadsDialog(
name = pending.name,
bytes = pending.bytes,
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null },
)
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
}
}
@@ -390,6 +381,7 @@ fun LibraryScreen(
onSync: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
onOpenSearch: () -> Unit = {},
offline: Boolean = false,
onExitOffline: () -> Unit = {},
@@ -424,9 +416,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 +442,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 +494,7 @@ fun LibraryScreen(
coverArt,
listBottomInset,
{
dismissSearch()
focusManager.clearFocus()
onAlbumClick(it)
},
onLongClick = onItemLongClick,
@@ -523,7 +514,7 @@ fun LibraryScreen(
coverArt,
listBottomInset,
{
dismissSearch()
focusManager.clearFocus()
onArtistClick(it)
},
onLongClick = onItemLongClick,
@@ -534,6 +525,7 @@ fun LibraryScreen(
onSync = onSync,
downloadStatuses = artistDownloads,
scrollToTopKey = scrollToTopKey,
artistArt = artistArt,
)
}
@@ -543,7 +535,7 @@ fun LibraryScreen(
coverArt,
listBottomInset,
{
dismissSearch()
focusManager.clearFocus()
onPlaylistClick(it)
},
onLongClick = onItemLongClick,
@@ -583,8 +575,6 @@ fun LibraryScreen(
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
},
onOpenSettings = onOpenSettings,
offline = offline,
onExitOffline = onExitOffline,
artwork = artwork,
syncing = syncing,
)
@@ -595,10 +585,7 @@ fun LibraryScreen(
SearchField(
value = search,
onValueChange = onSearchChange,
onClose = {
searchActive = false
onSearchChange("")
},
onClose = closeSearch,
focusRequester = searchFocus,
modifier =
Modifier
@@ -609,11 +596,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 +637,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 +708,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 +830,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 +863,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 +900,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()
}
@@ -7,7 +7,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -18,6 +17,7 @@ import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSongItem
@@ -27,16 +27,16 @@ import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.CloseWhenDownloadsGone
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
import com.subtracks.ui.components.rememberPendingDownloadDelete
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -55,21 +55,17 @@ 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()
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<Long?>(null) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow()) { _, _ -> viewModel.downloadedBytes() }
val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
if (action == BulkDownloadAction.Delete) {
scope.launch { pendingDelete = viewModel.downloadedBytes() }
} else {
viewModel.onDownloadAction(action)
pendingDelete.onAction(playlist?.name.orEmpty(), DownloadList.Playlist, playlistId, action) { _, _, requested ->
viewModel.onDownloadAction(requested)
}
}
val actions =
@@ -104,13 +100,8 @@ fun PlaylistDetailRoute(
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
)
pendingDelete?.let { bytes ->
DeleteDownloadsDialog(
name = playlist?.name.orEmpty(),
bytes = bytes,
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null },
)
PendingDownloadDeleteConfirmation(pendingDelete) {
viewModel.onDownloadAction(BulkDownloadAction.Delete)
}
}
@@ -138,6 +129,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,
@@ -90,6 +90,7 @@ 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
@@ -576,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)
}
@@ -32,6 +32,7 @@ internal fun replayGainModeLabel(
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(
@@ -74,6 +74,7 @@ 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
@@ -101,6 +102,7 @@ private enum class SettingsDialog {
SyncMode,
ReplayGain,
ReplayGainPreamp,
SortNames,
}
@Composable
@@ -128,6 +130,7 @@ fun SettingsRoute(
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(
@@ -143,6 +146,7 @@ fun SettingsRoute(
scrobbling = scrobbling,
replayGainMode = replayGainMode,
replayGainPreamp = replayGainPreamp,
sortNameMode = sortNameMode,
keepScreenOn = keepScreenOn,
offline = offline,
verboseLogging = verboseLogging,
@@ -158,6 +162,7 @@ fun SettingsRoute(
onScrobblingChange = viewModel::setScrobbling,
onReplayGainModeChange = viewModel::setReplayGainMode,
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
onSortNameModeChange = viewModel::setSortNameMode,
onKeepScreenOnChange = viewModel::setKeepScreenOn,
onOfflineChange = viewModel::setOfflineMode,
onVerboseLoggingChange = viewModel::setVerboseLogging,
@@ -203,8 +208,9 @@ fun SettingsScreen(
verboseLogging: Boolean = false,
syncMode: SyncMode = SyncMode.WifiOnly,
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
replayGainMode: ReplayGainMode = ReplayGainMode.Track,
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
replayGainPreamp: Float = 0f,
sortNameMode: SortNameMode = SortNameMode.Name,
onSelectSource: (Long) -> Unit,
onEditServer: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit,
@@ -217,6 +223,7 @@ fun SettingsScreen(
onScrobblingChange: (Boolean) -> Unit,
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
onReplayGainPreampChange: (Float) -> Unit = {},
onSortNameModeChange: (SortNameMode) -> Unit = {},
onKeepScreenOnChange: (Boolean) -> Unit = {},
onOfflineChange: (Boolean) -> Unit = {},
onVerboseLoggingChange: (Boolean) -> Unit = {},
@@ -348,6 +355,15 @@ fun SettingsScreen(
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(
@@ -603,6 +619,14 @@ fun SettingsScreen(
)
}
SettingsDialog.SortNames -> {
SortNameModeDialog(
selected = sortNameMode,
onSelect = onSortNameModeChange,
onDismiss = { dialog = null },
)
}
null -> {
Unit
}
@@ -756,6 +780,52 @@ 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,
@@ -6,6 +6,7 @@ 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
@@ -69,13 +70,18 @@ class SettingsViewModel(
val replayGainMode: StateFlow<ReplayGainMode> =
userPreferences
.replayGainMode()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Track)
.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()
@@ -143,4 +149,8 @@ class SettingsViewModel(
fun setKeepScreenOn(enabled: Boolean) {
viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) }
}
fun setSortNameMode(mode: SortNameMode) {
viewModelScope.launch { userPreferences.setSortNameMode(mode) }
}
}
+33
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,10 +79,12 @@
<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>
@@ -87,6 +97,11 @@
<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>
@@ -182,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>
@@ -0,0 +1,58 @@
package com.subtracks.data.db
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AlbumCreditsTest {
@Test
fun everyVariantUsesAllFourCreditPaths() {
val variants =
listOf(
CREDITED_ALBUM_ROWS,
DOWNLOADED_ALBUM_ROWS,
DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST,
DOWNLOADED_COVER_ALBUM_ROWS,
ARTIST_COVER_CREDITS,
ARTIST_COVER_CREDITS_FOR_ARTIST,
ALBUMS_FOR_ARTIST_DOWNLOADED,
)
variants.forEach { sql ->
assertEquals(sql, 1, sql.occurrences("FROM albums"))
assertEquals(sql, 1, sql.occurrences("FROM album_artists"))
assertEquals(sql, 1, sql.occurrences("FROM songs JOIN albums"))
assertEquals(sql, 1, sql.occurrences("FROM song_artists"))
}
}
@Test
fun variantsKeepTheirOwnFiltersAndDuplicateSemantics() {
assertFalse(CREDITED_ALBUM_ROWS.contains("coverArt IS NOT NULL"))
assertFalse(CREDITED_ALBUM_ROWS.contains("song_downloads"))
assertTrue(CREDITED_ALBUM_ROWS.contains(" UNION ALL "))
assertEquals(4, ARTIST_COVER_CREDITS.occurrences("coverArt IS NOT NULL"))
assertEquals(4, ARTIST_COVER_CREDITS_FOR_ARTIST.occurrences("= :artistId"))
assertEquals(4, DOWNLOADED_ALBUM_ROWS.occurrences("sd.status = 'Completed'"))
assertEquals(4, DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST.occurrences("= :artistId"))
assertEquals(4, DOWNLOADED_COVER_ALBUM_ROWS.occurrences("coverArt IS NOT NULL"))
assertTrue(DOWNLOADED_ALBUM_ROWS.contains(" UNION "))
assertFalse(DOWNLOADED_ALBUM_ROWS.contains(" UNION ALL "))
assertFalse(DOWNLOADED_ALBUM_ROWS.contains("creditType"))
}
@Test
fun consumersEmbedTheSharedVariants() {
assertTrue(ALBUMS_FOR_ARTIST.contains(CREDITED_ALBUM_ROWS))
assertTrue(ALBUM_CREDIT_ALBUM_IDS.contains(CREDITED_ALBUM_ROWS))
assertTrue(ARTIST_AGGREGATES_SQL.contains(CREDITED_ALBUM_ROWS))
assertTrue(DOWNLOADED_ARTIST_ALBUMS.contains(DOWNLOADED_ALBUM_ROWS))
assertTrue(ALBUMS_FOR_ARTIST_DOWNLOADED.contains(DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST))
assertTrue(ARTIST_COVER_ARTS_SQL.contains(ARTIST_COVER_CREDITS_FOR_ARTIST))
assertTrue(ARTIST_ART_UPDATE_SQL.contains(ARTIST_COVER_CREDITS))
assertTrue(ARTIST_ART_UPDATE_SQL.contains(DOWNLOADED_COVER_ALBUM_ROWS))
assertTrue(DOWNLOADED_ARTIST_ART_UPDATE_SQL.contains(DOWNLOADED_COVER_ALBUM_ROWS))
}
private fun String.occurrences(value: String): Int = split(value).size - 1
}
@@ -1,9 +1,5 @@
package com.subtracks.data.db
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.Source
@@ -22,12 +18,7 @@ class ArtworkSeedDaoTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
}
@After
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,7 @@
package com.subtracks.data.db
import android.content.Context
import androidx.paging.PagingSource
import androidx.room3.Room
import androidx.room3.useWriterConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
@@ -27,12 +23,7 @@ class LibraryHomeDaoTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
}
@After
@@ -0,0 +1,109 @@
package com.subtracks.data.db
import androidx.paging.PagingSource
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SortNameMode
internal fun LibraryDao.albumPages(
sourceId: Long,
starredFilter: Int = 0,
search: String = "",
sort: AlbumSort = AlbumSort.Name,
descending: Boolean = false,
downloaded: Boolean = false,
sortNameMode: SortNameMode = SortNameMode.SortName,
): PagingSource<Int, Album> =
if (downloaded) {
downloadedAlbumsPaging(
albumsPageQuery(
sourceId = sourceId,
starredFilter = starredFilter,
search = search,
sort = sort,
descending = descending,
downloaded = true,
sortNameMode = sortNameMode,
),
)
} else {
albumsPaging(
albumsPageQuery(
sourceId = sourceId,
starredFilter = starredFilter,
search = search,
sort = sort,
descending = descending,
downloaded = false,
sortNameMode = sortNameMode,
),
)
}
internal fun LibraryDao.artistPages(
sourceId: Long,
starredFilter: Int = 0,
search: String = "",
sort: ArtistSort = ArtistSort.Name,
descending: Boolean = false,
downloaded: Boolean = false,
sortNameMode: SortNameMode = SortNameMode.SortName,
): PagingSource<Int, Artist> =
if (downloaded) {
downloadedArtistsPaging(
artistsPageQuery(
sourceId = sourceId,
starredFilter = starredFilter,
search = search,
sort = sort,
descending = descending,
downloaded = true,
sortNameMode = sortNameMode,
),
)
} else {
artistsPaging(
artistsPageQuery(
sourceId = sourceId,
starredFilter = starredFilter,
search = search,
sort = sort,
descending = descending,
downloaded = false,
sortNameMode = sortNameMode,
),
)
}
internal fun LibraryDao.playlistPages(
sourceId: Long,
search: String = "",
sort: PlaylistSort = PlaylistSort.Name,
descending: Boolean = false,
downloaded: Boolean = false,
): PagingSource<Int, Playlist> =
if (downloaded) {
downloadedPlaylistsPaging(
playlistsPageQuery(
sourceId = sourceId,
search = search,
sort = sort,
descending = descending,
downloaded = true,
),
)
} else {
playlistsPaging(
playlistsPageQuery(
sourceId = sourceId,
search = search,
sort = sort,
descending = descending,
downloaded = false,
),
)
}
@@ -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
@@ -136,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 {
@@ -272,6 +395,94 @@ class MigrationsTest {
}
}
@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,
@@ -290,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,
@@ -323,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))
}
}
}
@@ -1,12 +1,9 @@
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
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
@@ -14,6 +11,9 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
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.source.MusicSource
import com.subtracks.data.source.StarType
import com.subtracks.data.sync.SyncService
@@ -37,9 +37,8 @@ class QueryTimingMeasurementTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
db = Room.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java).setDriver(driver).build()
db = createTestDatabase(driver)
}
@After
@@ -105,28 +104,37 @@ class QueryTimingMeasurementTest {
private suspend fun measureReads() {
val dao = db.libraryDao()
timed("albums first page (name)") { dao.albumsByName(1, 0, "").firstPage() }
timed("albums first page (artist)") { dao.albumsByArtist(1, 0, "").firstPage() }
timed("albums first page (year)") { dao.albumsByYear(1, 0, "").firstPage() }
timed("albums first page (added)") { dao.albumsByRecentlyAdded(1, 0, "").firstPage() }
timed("albums first page (starred)") { dao.albumsByStarred(1, 0, "").firstPage() }
timed("albums first page (frequent)") { dao.albumsByFrequent(1, 0, "").firstPage() }
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("albums first page (name)") { dao.albumPages(1, 0, "", sort = AlbumSort.Name).firstPage() }
timed("albums first page (name, downloaded)") {
dao.albumPages(1, 0, "", sort = AlbumSort.Name, downloaded = true).firstPage()
}
timed("albums first page (artist)") { dao.albumPages(1, 0, "", sort = AlbumSort.Artist).firstPage() }
timed("albums first page (year)") { dao.albumPages(1, 0, "", sort = AlbumSort.Year).firstPage() }
timed("albums first page (added)") { dao.albumPages(1, 0, "", sort = AlbumSort.Added).firstPage() }
timed("albums first page (starred)") { dao.albumPages(1, 0, "", sort = AlbumSort.Starred).firstPage() }
timed("albums first page (frequent)") { dao.albumPages(1, 0, "", sort = AlbumSort.Frequent).firstPage() }
timed("albums first page (recent)") { dao.albumPages(1, 0, "", sort = AlbumSort.Recent).firstPage() }
timed("artists first page (name)") { dao.artistPages(1, 0, "", sort = ArtistSort.Name).firstPage() }
timed("artists first page (name, downloaded)") {
dao.artistPages(1, 0, "", sort = ArtistSort.Name, downloaded = true).firstPage()
}
timed("artists first page (frequent)") { dao.artistPages(1, 0, "", sort = ArtistSort.Frequent).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("artists downloaded filter") { dao.artistPages(1, 0, "", downloaded = true).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() }
timed("albums search short") { dao.albumsByName(1, 0, "al").firstPage() }
timed("playlists first page (name)") { dao.playlistPages(1, "", sort = PlaylistSort.Name).firstPage() }
timed("playlists first page (name, downloaded)") {
dao.playlistPages(1, "", sort = PlaylistSort.Name, downloaded = true).firstPage()
}
timed("albums deep page") { dao.albumPages(1, 0, "", sort = AlbumSort.Name).deepPage(1000) }
timed("albums search FTS") { dao.albumPages(1, 0, "Album 1", sort = AlbumSort.Name).firstPage() }
timed("albums search short") { dao.albumPages(1, 0, "al", sort = AlbumSort.Name).firstPage() }
timed("songs search FTS") { dao.searchSongs(1, "Song", 50).first() }
timed("home recently played") { dao.recentlyPlayedAlbums(1, 20).first() }
timed("home most played") { dao.mostPlayedAlbums(1, 20).first() }
@@ -134,7 +142,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,16 @@
package com.subtracks.data.db
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.SQLiteDriver
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
fun createTestDatabase(driver: SQLiteDriver = BundledSQLiteDriver()): SubtracksDatabase {
val context = ApplicationProvider.getApplicationContext<Context>()
return Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(driver)
.addSubtracksDatabaseCallbacks()
.build()
}
@@ -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 {
@@ -210,13 +226,13 @@ class UserPreferencesTest {
}
@Test
fun replayGainDefaultsToTrackModeAndRoundTrips() =
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.Track, prefs.replayGainMode().first())
assertEquals(ReplayGainMode.Auto, prefs.replayGainMode().first())
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
prefs.setReplayGainMode(ReplayGainMode.Album)
@@ -225,6 +241,9 @@ class UserPreferencesTest {
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)
@@ -3,13 +3,12 @@ package com.subtracks.data.repo
import android.content.Context
import android.content.res.Resources
import android.net.Uri
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.download.ArtworkFetcher
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
@@ -26,6 +25,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 +36,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 +53,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 +70,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)
}
@@ -77,11 +83,7 @@ class DownloadRepositoryTest {
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
sources = SourceRepository(db, OkHttpClient(), prefs, artwork, scope = scope)
@@ -121,6 +123,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 +341,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 +352,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 +388,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 {
@@ -1234,12 +1378,23 @@ class DownloadRepositoryTest {
songCount = 2,
)
private fun artist(coverArt: String?) =
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2)
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(
@@ -1289,6 +1444,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(
@@ -4,15 +4,18 @@ 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
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
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
@@ -59,11 +62,7 @@ class LibraryRepositoryTest {
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
sourceRepository =
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
@@ -72,6 +71,7 @@ class LibraryRepositoryTest {
db,
sourceRepository,
NetworkServerActionSink(sourceRepository),
fakeUserPreferences(),
showMessage = { messages += it.resolve(resources) },
scope = scope,
)
@@ -203,6 +203,197 @@ class LibraryRepositoryTest {
}
}
@Test
fun theDownloadedAlbumGridOnlyPresentsDownloadedRows() =
runBlocking {
val sourceId = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
db.libraryDao().upsertAlbums(listOf(seedAlbum(sourceId, 1), seedAlbum(sourceId, 2)))
db.libraryDao().upsertSongs(
listOf(
seedSong(sourceId).copy(id = "s1", albumId = "al-0001"),
seedSong(sourceId).copy(id = "s2", albumId = "al-0002"),
),
)
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
val presenterDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
val presenter = RecordingPresenter(presenterDispatcher)
val job =
launch {
repository
.albums(
sourceId = sourceId,
sort = AlbumSort.Name,
descending = false,
starred = StarredFilter.Any,
search = "",
downloaded = true,
).collect { presenter.collectFrom(it) }
}
try {
awaitRefreshes(presenter, 1)
assertEquals(1, 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,
@@ -279,6 +470,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) =
@@ -1,13 +1,10 @@
package com.subtracks.data.repo
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.model.PendingActionKind
import com.subtracks.data.model.Source
import com.subtracks.data.source.ServerActionSink
@@ -82,12 +79,7 @@ class PendingServerActionSinkTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
sourceId = runBlocking { db.sourcesDao().insertSource(Source(name = "server", address = "http://x/", createdAt = 0)) }
delegate = RecordingSink()
@@ -1,12 +1,9 @@
package com.subtracks.data.repo
import android.content.Context
import androidx.room3.Room
import androidx.room3.useReaderConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
@@ -26,12 +23,7 @@ class QueueKeysetTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
repository = QueueRepository(db)
}
@@ -158,6 +150,18 @@ class QueueKeysetTest {
assertTrue("backward keyset seeks", repository.keysetSeeks > beforeBackward)
}
@Test
fun scanningAnAlbumWithNullDiscAndTrackForwardFallsBackToOffsetRows() =
runTest {
seedLibrary()
repository.replace(listOf(repository.albumEntry(1, "mixed")))
val snapshot = repository.snapshot()
val resolved = (0 until snapshot.size).map { repository.itemAt(snapshot, it)?.song?.id }
assertEquals(listOf("n1", "n2", "s1", "s2"), resolved)
}
@Test
fun scanningAnAlbumWithNullDiscAndTrackBackwardsResolvesEveryRow() =
runTest {
@@ -268,6 +272,22 @@ class QueueKeysetTest {
assertTrue(ids.all { it in expected })
}
@Test
fun thePlaylistWindowQuerySeeksThePositionIndex() =
runTest {
seedLibrary()
val plan =
queryPlan(
"SELECT songs.*, playlist_songs.position AS playlistPosition, albums.coverArt AS coverArt " +
"FROM playlist_songs JOIN songs ON songs.sourceId = playlist_songs.sourceId " +
"AND songs.id = playlist_songs.songId LEFT JOIN albums ON albums.sourceId = songs.sourceId " +
"AND albums.id = songs.albumId WHERE playlist_songs.sourceId = 1 " +
"AND playlist_songs.playlistId = 'pl1' ORDER BY playlist_songs.position LIMIT 51 OFFSET 100000",
)
assertTrue(plan.joinToString("\n"), plan.any { it.contains("sqlite_autoindex_playlist_songs_1") })
}
@Test
fun theAlbumKeysetQuerySeeksTheOrderIndex() =
runTest {
@@ -1,13 +1,10 @@
package com.subtracks.data.repo
import android.content.Context
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
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Playlist
@@ -33,12 +30,7 @@ class QueueRepositoryTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
repository = QueueRepository(db)
}
@@ -485,6 +477,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
@@ -661,6 +727,32 @@ class QueueRepositoryTest {
assertEquals(listOf("s1", "s2", "s3"), resolveAll(restored))
}
@Test
fun shuffledLargeContextWindowOnlyResolvesRequestedRows() =
runTest {
seedLibrary()
db.libraryDao().upsertPlaylists(listOf(playlist("huge", "Huge")))
db.useWriterConnection { transactor ->
transactor.usePrepared(
"WITH RECURSIVE n(x) AS (VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 199999) " +
"INSERT INTO playlist_songs SELECT 1, 'huge', 's1', x FROM n",
) { statement ->
statement.step()
}
}
repository.snapshotAfter(listOf(repository.playlistEntry(1, "huge")))
repository.setShuffle(42L)
val snapshot = repository.snapshot()
val window = repository.window(snapshot, center = 100_000, radius = 25)
assertEquals(200_000L, snapshot.contextSize)
assertEquals(51, window.size)
assertEquals(51L, repository.resolvedRowCount)
assertEquals((99_975L..100_025L).toList(), window.map { it.position })
assertTrue(window.all { it.item.song.id == "s1" })
}
@Test
fun shuffledRangeResolvesEntriesInPositionOrder() =
runTest {
@@ -785,6 +877,55 @@ class QueueRepositoryTest {
}
}
@Test
fun contextIndexUsesTheFirstDuplicateOrdinalAcrossSparsePositions() =
runTest {
seedLibrary()
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
db.libraryDao().upsertPlaylistSongs(
listOf(
PlaylistSong(1, "duplicates", "s2", 10),
PlaylistSong(1, "duplicates", "s1", 20),
PlaylistSong(1, "duplicates", "s2", 30),
),
)
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "duplicates")))
assertEquals(0L, repository.combinedContextIndexOf(snapshot, "s2"))
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s1"))
}
@Test
fun contextIndexFindsADuplicateInsideTheEntryRange() =
runTest {
seedLibrary()
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
db.libraryDao().upsertPlaylistSongs(
listOf(
PlaylistSong(1, "duplicates", "s2", 10),
PlaylistSong(1, "duplicates", "s1", 20),
PlaylistSong(1, "duplicates", "s2", 30),
),
)
val entry = repository.playlistEntry(1, "duplicates").copy(rangeStart = 1, rangeEnd = 2)
val snapshot = repository.snapshotAfter(listOf(entry))
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s2"))
}
@Test
fun contextIndexFallsBackToBoundedRowsForGenre() =
runTest {
seedLibrary()
insertGenre("s1", "Rock")
insertGenre("s3", "Rock")
insertGenre("s4", "Rock")
val snapshot = repository.snapshotAfter(listOf(repository.genreEntry(1, "Rock")))
assertEquals(2L, repository.combinedContextIndexOf(snapshot, "s4"))
assertTrue(repository.resolvedRowCount <= QUEUE_CHUNK)
}
@Test
fun upNextCombinedIndexOfCountsFromTheAnchorIntoTheBlock() =
runTest {
@@ -853,11 +994,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 {
@@ -2,15 +2,15 @@ package com.subtracks.data.repo
import android.content.Context
import android.content.res.Resources
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
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 +33,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
@@ -56,11 +57,7 @@ class SourceRepositoryTest {
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
prefs = fakeUserPreferences()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -283,7 +280,36 @@ class SourceRepositoryTest {
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:"))
}
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! }
@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.activeSource.first { it != null }!! }
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
withTimeout(TEST_TIMEOUT_MS) {
@@ -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 {
@@ -2,6 +2,7 @@ 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
@@ -42,6 +43,30 @@ class SubsonicXmlTest {
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 =
@@ -49,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>
@@ -63,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)
}
@@ -717,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) {
@@ -2,13 +2,12 @@ package com.subtracks.data.sync
import android.content.Context
import android.content.res.Resources
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences
@@ -55,11 +54,7 @@ class SyncManagerTest {
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
sourceRepository =
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
@@ -1,14 +1,15 @@
package com.subtracks.data.sync
import android.content.Context
import androidx.paging.PagingSource
import androidx.room3.Room
import androidx.room3.useWriterConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.LoggingSQLiteDriver
import com.subtracks.data.db.SqlExecution
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.artistsDefaultQuery
import com.subtracks.data.db.artistPages
import com.subtracks.data.db.artistsPageQuery
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRef
@@ -29,9 +30,11 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Collections
@RunWith(AndroidJUnit4::class)
class SyncServiceTest {
@@ -39,12 +42,7 @@ class SyncServiceTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
}
@After
@@ -71,7 +69,7 @@ class SyncServiceTest {
2,
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.size,
)
@@ -94,7 +92,7 @@ class SyncServiceTest {
listOf("a1"),
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.map { it.id },
)
@@ -168,7 +166,7 @@ class SyncServiceTest {
"Old",
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.single()
.name,
@@ -181,7 +179,7 @@ class SyncServiceTest {
"New",
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.single()
.name,
@@ -254,6 +252,19 @@ class SyncServiceTest {
assertEquals(5678L, stored?.starred)
}
@Test
fun syncStoresPlaylistOwnerAndSharing() =
runTest {
insertSource()
val source = FakeMusicSource(playlists = listOf(playlist("p1").copy(owner = "austin", shared = true)))
SyncService(db, source).sync()
val stored = db.libraryDao().playlist(1, "p1").first()
assertEquals("austin", stored?.owner)
assertEquals(true, stored?.shared)
}
@Test
fun syncStoresSongGenresInOrder() =
runTest {
@@ -1090,16 +1101,16 @@ class SyncServiceTest {
listOf("a1"),
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.map { it.id },
)
val query = artistsDefaultQuery(1, 0, "alpha", ArtistSort.Name, false)
val query = artistsPageQuery(1, 0, "alpha", ArtistSort.Name, false, false)
assertEquals(
listOf("a1"),
db
.libraryDao()
.artistsByName(1, 0, "alpha")
.artistPages(1, 0, "alpha")
.allRows()
.map { it.id },
)
@@ -1186,7 +1197,7 @@ class SyncServiceTest {
listOf("a1", "combo"),
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.map { it.id },
)
@@ -1199,7 +1210,7 @@ class SyncServiceTest {
listOf("a1"),
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.map { it.id },
)
@@ -1373,6 +1384,11 @@ class SyncServiceTest {
insertSource()
val source =
FakeMusicSource(
artists =
listOf(
artist("a1", "Artist").copy(albumCount = 9),
artist("a2", "Other").copy(albumCount = 9),
),
albums =
listOf(
album("al1").copy(artists = listOf(ArtistRef("a1", "Artist"), ArtistRef("a2", "Other"))),
@@ -1412,7 +1428,7 @@ class SyncServiceTest {
SyncService(db, source).sync()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
val filtered = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows()
val filtered = db.libraryDao().artistPages(1, 0, "", downloaded = true).allRows()
assertEquals(listOf("A", "B"), filtered.map { it.name })
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first())
@@ -1465,6 +1481,393 @@ class SyncServiceTest {
assertEquals(500L, db.libraryDao().artistOnce(1, "a2")!!.played)
}
@Test
fun trackOnlyCreditsReachTheArtistBadgeAndPlays() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "Album Artist"), artist("a2", "Guest").copy(albumCount = 7)),
albums =
listOf(
album("al1").copy(
artistId = "a1",
artists = listOf(ArtistRef("a1", "Album Artist")),
),
album("al2").copy(
artistId = "a2",
artists = listOf(ArtistRef("a2", "Guest")),
),
),
songs =
listOf(
song("s1").copy(
albumId = "al1",
artistId = "a1",
artists = listOf(ArtistRef("a1", "Album Artist"), ArtistRef("a2", "Guest")),
playCount = 3,
played = 100,
),
song("s2").copy(
albumId = "al1",
artistId = "a2",
artists = listOf(ArtistRef("a2", "Guest")),
playCount = 4,
played = 200,
),
),
)
SyncService(db, source).sync()
val guest = db.libraryDao().artistOnce(1, "a2")!!
assertEquals(2L, guest.albumCount)
assertEquals(2L, guest.songCount)
assertEquals(7L, guest.playCount)
assertEquals(200L, guest.played)
val albums = db.libraryDao().albumsForArtist(1, "a2").first()
assertEquals(listOf("al1", "al2"), albums.map { it.id }.sorted())
assertEquals(albums.size.toLong(), guest.albumCount)
assertEquals(
albums.size.toLong(),
db
.libraryDao()
.artistPages(1, 0, "")
.allRows()
.first { it.id == "a2" }
.albumCount,
)
assertEquals(guest.songCount, db.queueDao().artistLength(1, "a2"))
}
@Test
fun songsOnMissingAlbumsAreSongsButNotAlbums() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "Artist")),
albums = emptyList(),
songs =
listOf(
song("s1").copy(
albumId = "missing",
artistId = "a1",
artists = listOf(ArtistRef("a1", "Artist")),
),
),
)
SyncService(db, source).sync()
val artist = db.libraryDao().artistOnce(1, "a1")!!
assertEquals(1L, artist.songCount)
assertEquals(0L, artist.albumCount)
assertEquals(
emptyList<String>(),
db
.libraryDao()
.albumsForArtist(1, "a1")
.first()
.map { it.id },
)
}
@Test
fun albumlessTrackCreditsCountAndBumpOnPlay() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "Album Artist"), artist("a2", "Guest")),
albums = emptyList(),
songs =
listOf(
song("s1").copy(
albumId = null,
artistId = "a1",
artists = listOf(ArtistRef("a1", "Album Artist"), ArtistRef("a2", "Guest")),
playCount = 2,
played = 50,
),
),
)
SyncService(db, source).sync()
val guest = db.libraryDao().artistOnce(1, "a2")!!
assertEquals(0L, guest.albumCount)
assertEquals(1L, guest.songCount)
assertEquals(2L, guest.playCount)
assertEquals(50L, guest.played)
db.libraryDao().recordPlay(1, "s1", 300)
assertEquals(3L, db.libraryDao().artistOnce(1, "a2")!!.playCount)
assertEquals(300L, db.libraryDao().artistOnce(1, "a2")!!.played)
}
@Test
fun leadIdsOutsideThePluralRefsKeepArtistTotalsAndShufflesAligned() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "Lead"), artist("a2", "Ref")),
albums =
listOf(
album("al1").copy(
artistId = "a1",
artists = listOf(ArtistRef("a2", "Ref")),
),
),
songs =
listOf(
song("s1").copy(
albumId = "al1",
artistId = "a1",
artists = listOf(ArtistRef("a2", "Ref")),
),
),
)
SyncService(db, source).sync()
val lead = db.libraryDao().artistOnce(1, "a1")!!
assertEquals(1L, lead.songCount)
assertEquals(1L, lead.albumCount)
assertEquals(db.queueDao().artistLength(1, "a1"), lead.songCount)
assertEquals(
lead.songCount,
db
.downloadDao()
.artistStatus(1, "a1")
.first()
.total,
)
}
@Test
fun albumCreditIdsFollowPluralCredits() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "A"), artist("a2", "B")),
albums =
listOf(
album("al1").copy(artistId = "a1", artists = listOf(ArtistRef("a1", "A"))),
album("al2").copy(artistId = "a2", artists = listOf(ArtistRef("a2", "B"))),
album("al3").copy(
artistId = "a2",
artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")),
),
),
songs =
listOf(
song("s1").copy(
albumId = "al1",
artistId = "a1",
artists = listOf(ArtistRef("a1", "A")),
),
song("s2").copy(
albumId = "al2",
artistId = "a2",
artists = listOf(ArtistRef("a2", "B"), ArtistRef("a1", "A")),
),
song("s3").copy(
albumId = "al3",
artistId = "a2",
artists = listOf(ArtistRef("a1", "A"), ArtistRef("a2", "B")),
),
),
)
SyncService(db, source).sync()
assertEquals(
listOf("al1", "al3"),
db
.libraryDao()
.albumCreditAlbumIds(1, "a1")
.first()
.sorted(),
)
assertEquals(
listOf("al2", "al3"),
db
.libraryDao()
.albumCreditAlbumIds(1, "a2")
.first()
.sorted(),
)
assertEquals(
listOf("al1", "al2", "al3"),
db
.libraryDao()
.albumsForArtist(1, "a1")
.first()
.map { it.id }
.sorted(),
)
}
@Test
fun everyArtistConsumerAgreesOnTheCreditedSet() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "Album Artist"), artist("a2", "Guest"), artist("a3", "Track Only")),
albums =
listOf(
album("al1").copy(artistId = "a1", artists = listOf(ArtistRef("a1", "Album Artist"))),
album("al2").copy(artistId = "a2", artists = listOf(ArtistRef("a2", "Guest"))),
),
songs =
listOf(
song("s1").copy(
albumId = "al1",
artistId = "a1",
artists = listOf(ArtistRef("a1", "Album Artist"), ArtistRef("a2", "Guest")),
playCount = 1,
played = 100,
),
song("s2").copy(
albumId = "al1",
artistId = "a2",
artists = listOf(ArtistRef("a2", "Guest"), ArtistRef("a3", "Track Only")),
),
song("s3").copy(
albumId = null,
artistId = "a3",
artists = listOf(ArtistRef("a3", "Track Only")),
),
song("s4").copy(
albumId = "missing",
artistId = "a2",
artists = listOf(ArtistRef("a2", "Guest")),
),
),
)
SyncService(db, source).sync()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s4", DownloadStatus.Completed))
for (artistId in listOf("a1", "a2", "a3")) {
val artist = db.libraryDao().artistOnce(1, artistId)!!
val songIds = db.queueDao().artistSongIds(1, artistId)
val albums = db.libraryDao().albumsForArtist(1, artistId).first()
val status = db.downloadDao().artistStatus(1, artistId).first()
val downloaded =
db
.downloadDao()
.all()
.count { it.status == DownloadStatus.Completed && it.songId in songIds }
assertEquals("$artistId albums", albums.size.toLong(), artist.albumCount)
assertEquals("$artistId songs", songIds.size.toLong(), artist.songCount)
assertEquals("$artistId length", songIds.size.toLong(), db.queueDao().artistLength(1, artistId))
assertEquals("$artistId total", songIds.size.toLong(), status.total)
assertEquals("$artistId downloaded", downloaded.toLong(), status.downloaded)
}
val downloadedArtists = db.libraryDao().artistPages(1, 0, "", downloaded = true).allRows()
for (artist in downloadedArtists) {
assertEquals(
"${artist.id} downloaded albums",
db
.libraryDao()
.albumsForArtistDownloaded(1, artist.id)
.first()
.size
.toLong(),
artist.albumCount,
)
}
}
@Test
fun changingOnlyTheLeadArtistRecomputesTheCreditedCounts() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", "Lead"), artist("a2", "Ref")),
albums =
listOf(
album("al1").copy(
artistId = "a1",
artists = listOf(ArtistRef("a2", "Ref")),
),
),
songs =
listOf(
song("s1").copy(
albumId = "al1",
artistId = "a1",
artists = listOf(ArtistRef("a2", "Ref")),
),
),
)
SyncService(db, source).sync()
assertEquals(1L, db.libraryDao().artistOnce(1, "a1")!!.songCount)
assertEquals(1L, db.libraryDao().artistOnce(1, "a1")!!.albumCount)
source.albums = listOf(album("al1").copy(artistId = "a2", artists = listOf(ArtistRef("a2", "Ref"))))
source.songs =
listOf(
song("s1").copy(
albumId = "al1",
artistId = "a2",
artists = listOf(ArtistRef("a2", "Ref")),
),
)
SyncService(db, source).sync()
assertEquals(0L, db.libraryDao().artistOnce(1, "a1")!!.songCount)
assertEquals(0L, db.libraryDao().artistOnce(1, "a1")!!.albumCount)
assertEquals(1L, db.libraryDao().artistOnce(1, "a2")!!.songCount)
assertEquals(1L, db.libraryDao().artistOnce(1, "a2")!!.albumCount)
}
@Test
fun anIdenticalSyncSkipsTheAggregateQueries() =
runTest {
val executions = Collections.synchronizedList(mutableListOf<SqlExecution>())
val loggingDb = createTestDatabase(LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it })
try {
loggingDb.sourcesDao().upsertSource(
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
)
val source =
FakeMusicSource(
artists = listOf(artist("a1", "Artist")),
albums = listOf(album("al1")),
songs = listOf(song("s1")),
)
SyncService(loggingDb, source).sync()
val firstSyncExecutions = synchronized(executions) { executions.toList() }
assertTrue(firstSyncExecutions.any { it.sql.contains("credited_albums") })
assertTrue(firstSyncExecutions.any { it.sql.contains("UPDATE albums SET playCount") })
synchronized(executions) { executions.clear() }
SyncService(loggingDb, source).sync()
val secondSyncExecutions = synchronized(executions) { executions.toList() }
assertTrue(secondSyncExecutions.none { it.sql.contains("credited_albums") })
assertTrue(secondSyncExecutions.none { it.sql.contains("UPDATE albums SET playCount") })
} finally {
loggingDb.close()
}
}
private suspend fun totalChanges(): Long =
db.useWriterConnection { connection ->
connection.usePrepared("SELECT total_changes()") { statement ->
@@ -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
@@ -70,6 +71,35 @@ class MediaItemTest {
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)
@@ -3,9 +3,7 @@ package com.subtracks.playback
import android.content.Context
import android.content.res.Resources
import androidx.media3.common.PlaybackException
import androidx.room3.Room
import androidx.room3.useWriterConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.R
@@ -14,6 +12,7 @@ import com.subtracks.UiMessage
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Album
@@ -83,11 +82,7 @@ class PlaybackControllerTest {
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
prefs = fakeUserPreferences()
networkMode = MutableStateFlow(NetworkMode.Wifi)
sources =
@@ -479,6 +474,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)
@@ -1268,6 +1553,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 {
@@ -1,6 +1,7 @@
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
@@ -35,6 +36,51 @@ class QueueItemTest {
)
}
@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 =
@@ -38,6 +38,17 @@ class ReplayGainControllerTest {
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))
@@ -65,6 +76,8 @@ class ReplayGainControllerTest {
id: String,
trackGain: Float?,
trackPeak: Float? = null,
albumGain: Float? = null,
albumContext: Boolean = false,
) = queueMediaItem(
QueueItem(
id = id,
@@ -72,7 +85,8 @@ class ReplayGainControllerTest {
artist = null,
album = null,
coverArtId = null,
replayGain = ReplayGain(trackGain = trackGain, trackPeak = trackPeak),
replayGain = ReplayGain(trackGain = trackGain, albumGain = albumGain, trackPeak = trackPeak),
albumContext = albumContext,
),
local = null,
stream = "http://server/$id",
@@ -53,6 +53,40 @@ class ReplayGainTest {
)
}
@Test
fun autoModeAppliesTheAlbumGainInAnAlbumContext() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -1f, albumGain = -6.0206f, trackPeak = 1f, albumPeak = 1f),
ReplayGainSettings(ReplayGainMode.Auto, 0f),
albumContext = true,
),
TOLERANCE,
)
}
@Test
fun autoModeAppliesTheTrackGainInAMixedContext() {
assertEquals(
0.5f,
replayGainLinearGain(
ReplayGain(trackGain = -6.0206f, albumGain = -1f, trackPeak = 1f, albumPeak = 1f),
ReplayGainSettings(ReplayGainMode.Auto, 0f),
albumContext = false,
),
TOLERANCE,
)
}
@Test
fun autoModeFallsBackInEitherContext() {
val settings = ReplayGainSettings(ReplayGainMode.Auto, 0f)
assertEquals(0.5f, replayGainLinearGain(ReplayGain(trackGain = -6.0206f), settings, albumContext = true), TOLERANCE)
assertEquals(0.5f, replayGainLinearGain(ReplayGain(albumGain = -6.0206f), settings, albumContext = false), TOLERANCE)
}
@Test
fun offAppliesUnityGain() {
assertEquals(

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