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

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

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

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

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

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

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

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

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

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

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

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

Download artwork also caches the provider image for the track and album artists, falls back to the artist cover when that fetch fails, and keeps both keys across sweeps. Query timing for the materialization pass is down from roughly 3.4s to 1.9s on the 20k-song benchmark.
2026-10-20 08:53:23 +00:00
austinried 0d1118011f ui: use the album grid for artist icons too
Load up to four thumbnail covers per visible artist row on demand and render the same 1-4 layout inside the circular artist icon. Lists stay on the expensive per-row query for now.
2026-10-20 07:27:42 +00:00
austinried 6e60ff6183 test: render the three-cover artist hero grid 2026-10-20 02:50:19 +00:00
austinried 4a85b23383 ui: prototype a multi-album artist hero fallback
When an artist has no portrait, build a 1-4 cover hero from album credits on demand. Uses full-res for one cover and thumbnails for 2-4; three covers use a Y split.
2026-10-20 02:48:18 +00:00
87 changed files with 9847 additions and 487 deletions
+4 -2
View File
@@ -85,7 +85,9 @@ jobs:
echo "value=false" >> "$GITHUB_OUTPUT" echo "value=false" >> "$GITHUB_OUTPUT"
fi fi
- name: Check release notes exist - 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 - name: Write Play service account
if: steps.stable.outputs.value == 'true' if: steps.stable.outputs.value == 'true'
env: env:
@@ -123,7 +125,7 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | 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 if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
args+=(--prerelease) args+=(--prerelease)
fi 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. - **Adaptive Theming:** Colours drawn from the current song's artwork.
- **Search:** Fast search across the whole library, with per-list sort and filter controls. - **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. - **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. - **Up Next:** Add items into the currently playing queue without rewriting it.
- **Multiple Servers:** Connect several servers and switch between them quickly. - **Multiple Servers:** Connect several servers and switch between them quickly.
- **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching. - **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching.
+2 -2
View File
@@ -36,8 +36,8 @@ android {
applicationId = "com.subtracks" applicationId = "com.subtracks"
minSdk = 24 minSdk = 24
targetSdk = 37 targetSdk = 37
versionCode = 13 versionCode = 14
versionName = "3.0.0" versionName = "3.1.0"
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"") buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,7 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase {
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db") .databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
.setDriver(driver) .setDriver(driver)
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray()) .addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
.addCallback(ArtistSearchTriggerOptimization)
.addCallback(SqliteLifecycleLogging) .addCallback(SqliteLifecycleLogging)
.build() .build()
} }
@@ -114,6 +114,16 @@ class LoggedMigration(
} }
} }
internal object ArtistSearchTriggerOptimization : RoomDatabase.Callback() {
override suspend fun onCreate(connection: SQLiteConnection) {
optimizeArtistSearchTriggers(connection)
}
override suspend fun onOpen(connection: SQLiteConnection) {
if (!artistSearchTriggersOptimized(connection)) optimizeArtistSearchTriggers(connection)
}
}
internal object SqliteLifecycleLogging : RoomDatabase.Callback() { internal object SqliteLifecycleLogging : RoomDatabase.Callback() {
override suspend fun onCreate(connection: SQLiteConnection) { override suspend fun onCreate(connection: SQLiteConnection) {
Log.i("sql", "created fresh database") Log.i("sql", "created fresh database")
@@ -162,12 +162,15 @@ interface DownloadDao {
@Query( @Query(
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " + "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, " + "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 " + "FROM song_downloads d " +
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " + "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 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> suspend fun artwork(): List<DownloadArtwork>
@@ -50,11 +50,6 @@ internal const val ALBUMS_FILTER =
"OR instr(lower(sortName), lower(:search)) > 0 " + "OR instr(lower(sortName), lower(:search)) > 0 " +
"OR instr(lower(albumArtistSort), 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 = "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_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id" internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
@@ -70,6 +65,16 @@ 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 = "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" internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
private const val ALBUM_NAME_SORT_KEY_MODE =
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
private const val ALBUM_ARTIST_SORT_KEY_MODE =
"(CASE :sortMode WHEN 'Name' THEN albumArtist WHEN 'NonLatin' THEN (CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) ELSE albumArtistSort END) COLLATE NOCASE"
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id"
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE = "$ALBUM_ARTIST_SORT_KEY_MODE, year, $ALBUM_NAME_SORT_KEY_MODE, id"
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED =
"$ALBUM_ARTIST_SORT_KEY_MODE DESC, year DESC, $ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
internal const val ARTIST_CREDITED = internal const val ARTIST_CREDITED =
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)" "(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
@@ -124,32 +129,176 @@ internal const val ALBUM_CREDIT_ALBUM_IDS =
// Download visibility follows the credit path: an album credit includes the album when any song is // Download visibility follows the credit path: an album credit includes the album when any song is
// downloaded, while a track credit includes it only when the credited track is downloaded. // downloaded, while a track credit includes it only when the credited track is downloaded.
private const val DOWNLOADED_ARTIST_ALBUMS = internal const val DOWNLOADED_ALBUM_ROWS =
"SELECT albums.id FROM albums " + "SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId = artists.id " + "WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " + "AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + "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') " + "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " + "UNION " +
"SELECT albums.id FROM album_artists " + "SELECT albums.id, album_artists.artistId FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " + "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 " + "WHERE album_artists.sourceId = :sourceId " +
"AND EXISTS (SELECT 1 FROM songs " + "AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " + "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') " + "WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " + "UNION " +
"SELECT songs.albumId FROM songs " + "SELECT songs.albumId, songs.artistId FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "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 " + "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = artists.id " + "WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
"AND sd.status = 'Completed' " +
"UNION " + "UNION " +
"SELECT songs.albumId FROM song_artists " + "SELECT songs.albumId, song_artists.artistId FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " + "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 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 " + "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 " + "WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
"AND sd.status = 'Completed'"
private const val DOWNLOADED_ARTIST_ALBUMS =
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
private const val DOWNLOADED_COVER_ALBUM_ROWS =
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.id, album_artists.artistId FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT songs.albumId, songs.artistId FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"AND sd.status = 'Completed' " +
"UNION " +
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL AND sd.status = 'Completed'"
// Cover art only needs credited albums that actually have a cover, so filter before the window sort.
private const val ARTIST_COVER_CREDITS =
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId, albums.id, 'album' " +
"FROM album_artists JOIN albums " +
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT songs.artistId, songs.albumId, 'track' " +
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId, songs.albumId, 'track' " +
"FROM song_artists JOIN songs " +
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL"
private const val ARTIST_COVER_CREDITS_FOR_ARTIST =
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
"AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId, albums.id, 'album' " +
"FROM album_artists JOIN albums " +
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
"AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT songs.artistId, songs.albumId, 'track' " +
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId, songs.albumId, 'track' " +
"FROM song_artists JOIN songs " +
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
"AND albums.coverArt IS NOT NULL"
private const val ARTIST_COVER_RANKS_TAIL =
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
"ORDER BY credits.creditRank, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
"FROM credits JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
"WHERE albums.coverArt IS NOT NULL), " +
"unique_ranked AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM ranked GROUP BY artistId, coverArt), " +
"deduped AS (SELECT artistId, coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
private const val ARTIST_COVER_RANKS =
"credits AS (SELECT artistId, albumId, " +
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
ARTIST_COVER_RANKS_TAIL
private const val ARTIST_COVER_RANKS_FOR_ARTIST =
"credits AS (SELECT artistId, albumId, " +
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
ARTIST_COVER_RANKS_TAIL
internal const val ARTIST_ART_UPDATE_SQL =
"WITH $ARTIST_COVER_RANKS, " +
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
"SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
") GROUP BY artistId), " +
"downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS)), " +
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
"WHERE albums.coverArt IS NOT NULL), " +
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
"downloaded_deduped AS (SELECT artistId, coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
") GROUP BY artistId), " +
"desired AS (SELECT artists.rowid AS rowid, " +
"COALESCE((SELECT coverArts FROM covers WHERE covers.artistId = artists.id), '') AS coverArts, " +
"COALESCE((SELECT coverArts FROM downloaded_covers " +
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
"FROM artists WHERE artists.sourceId = :sourceId) " +
"UPDATE artists SET coverArts = desired.coverArts, downloadedCoverArts = desired.downloadedCoverArts " +
"FROM desired WHERE artists.rowid = desired.rowid AND " +
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
internal const val ARTIST_COVER_ARTS_SQL =
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
"WHERE artistId IN (:artistIds)), " +
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY dl.artistId " +
"ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id) AS coverRank " +
"FROM downloaded_albums dl JOIN albums ON albums.sourceId = :sourceId AND albums.id = dl.albumId " +
"WHERE albums.coverArt IS NOT NULL), " +
"unique_downloaded AS (SELECT artistId, coverArt, MIN(coverRank) AS coverRank FROM downloaded GROUP BY artistId, coverArt), " +
"downloaded_deduped AS (SELECT artistId, coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_downloaded), " +
"downloaded_covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
"SELECT artistId, coverArt, coverRank FROM downloaded_deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
") GROUP BY artistId), " +
"desired AS (SELECT artists.rowid AS rowid, " +
"COALESCE((SELECT coverArts FROM downloaded_covers " +
"WHERE downloaded_covers.artistId = artists.id), '') AS downloadedCoverArts " +
"FROM artists WHERE artists.sourceId = :sourceId AND artists.id IN (:artistIds)) " +
"UPDATE artists SET downloadedCoverArts = desired.downloadedCoverArts " +
"FROM desired WHERE artists.rowid = desired.rowid AND " +
"artists.downloadedCoverArts != desired.downloadedCoverArts"
internal const val ARTISTS_FILTER = internal const val ARTISTS_FILTER =
"FROM artists WHERE sourceId = :sourceId " + "FROM artists WHERE sourceId = :sourceId " +
@@ -164,40 +313,10 @@ internal const val ARTISTS_FILTER =
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " + "OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
"OR instr(lower(sortName), lower(:search)) > 0))) " "OR instr(lower(sortName), lower(:search)) > 0))) "
internal const val ARTIST_ALBUM_CREDIT_ART =
"SELECT albums.coverArt FROM (" +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM albums " +
"INDEXED BY index_albums_sourceId_artistId " +
"WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id " +
"UNION ALL " +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id" +
") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1"
internal const val ARTIST_TRACK_CREDIT_ART =
"SELECT albums.coverArt FROM (" +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = artists.sourceId AND songs.artistId = artists.id " +
"UNION ALL " +
"SELECT albums.id, albums.coverArt, albums.year, albums.sortName 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 = artists.sourceId AND song_artists.artistId = artists.id" +
") albums ORDER BY albums.year DESC, albums.sortName COLLATE NOCASE, albums.id LIMIT 1"
internal const val ARTIST_COVER_ART =
"COALESCE(artists.coverArt, CASE " +
"WHEN EXISTS (SELECT 1 FROM albums WHERE albums.sourceId = artists.sourceId AND albums.artistId = artists.id) " +
"OR EXISTS (SELECT 1 FROM album_artists " +
"WHERE album_artists.sourceId = artists.sourceId AND album_artists.artistId = artists.id) " +
"THEN ($ARTIST_ALBUM_CREDIT_ART) ELSE ($ARTIST_TRACK_CREDIT_ART) END)"
internal const val ARTIST_COLUMNS = internal const val ARTIST_COLUMNS =
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " + "artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
"$ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " + "artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
"artists.sortName, artists.credited" "artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited"
// With the Downloaded filter on, count the albums that actually have a download so the number under // 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. // an artist matches the albums its detail screen lists.
@@ -206,11 +325,9 @@ internal const val ARTISTS_SELECT =
"CASE WHEN :downloadedFilter = 1 THEN (" + "CASE WHEN :downloadedFilter = 1 THEN (" +
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" + "SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
") ELSE artists.albumCount END AS albumCount, " + ") ELSE artists.albumCount END AS albumCount, " +
"artists.starred, $ARTIST_COVER_ART AS coverArt, artists.playCount, artists.played, artists.songCount, " + "artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
"artists.sortName, artists.credited " "artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited "
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 = "albumCount DESC, sortName COLLATE NOCASE, id" 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_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 = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
@@ -220,6 +337,15 @@ 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 = "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 ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
private const val ARTIST_NAME_SORT_KEY_MODE =
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE = "$ARTIST_NAME_SORT_KEY_MODE, id"
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ARTIST_NAME_SORT_KEY_MODE DESC, id DESC"
private const val SONG_NAME_SORT_KEY_MODE =
"(CASE :sortMode WHEN 'Name' THEN songs.title WHEN 'NonLatin' THEN (CASE WHEN songs.title GLOB '*[A-Za-z]*' THEN songs.title ELSE songs.sortName END) ELSE songs.sortName END) COLLATE NOCASE"
internal const val SONG_SEARCH_ORDER_SORT_MODE = "$SONG_NAME_SORT_KEY_MODE, songs.id"
internal const val PLAYLISTS_FILTER = internal const val PLAYLISTS_FILTER =
"FROM playlists WHERE sourceId = :sourceId " + "FROM playlists WHERE sourceId = :sourceId " +
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM playlist_songs ps " + "AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM playlist_songs ps " +
@@ -239,7 +365,7 @@ internal const val PLAYLISTS_SELECT =
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " + "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 " + "WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
") ELSE playlists.songCount END AS songCount, " + ") ELSE playlists.songCount END AS songCount, " +
"playlists.created, playlists.changed, playlists.duration " "playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared "
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id" internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC" internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
@@ -334,10 +460,29 @@ interface LibraryDao {
) )
suspend fun recomputeArtistAggregates(sourceId: Long) suspend fun recomputeArtistAggregates(sourceId: Long)
@Query(ARTIST_ART_UPDATE_SQL)
suspend fun recomputeArtistArt(sourceId: Long)
@Query(ARTIST_COVER_ARTS_SQL)
fun artistCoverArts(
sourceId: Long,
artistId: String,
): Flow<List<String>>
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
suspend fun recomputeDownloadedArtistArt(
sourceId: Long,
artistIds: List<String>,
)
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
suspend fun clearDownloadedArtistArt(sourceId: Long)
@Transaction @Transaction
suspend fun recomputeAggregates(sourceId: Long) { suspend fun recomputeAggregates(sourceId: Long) {
recomputeAlbumPlayData(sourceId) recomputeAlbumPlayData(sourceId)
recomputeArtistAggregates(sourceId) recomputeArtistAggregates(sourceId)
recomputeArtistArt(sourceId)
} }
@Query( @Query(
@@ -381,6 +526,12 @@ interface LibraryDao {
songId: String, songId: String,
): 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 // 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. // sync then overwrites with the server's own number.
@Transaction @Transaction
@@ -471,22 +622,24 @@ interface LibraryDao {
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " + "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( fun searchAlbums(
sourceId: Long, sourceId: Long,
search: String, search: String,
limit: Int, limit: Int,
sortMode: String = "",
): Flow<List<Album>> ): Flow<List<Album>>
@Query( @Query(
"SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " + "SELECT $ARTIST_COLUMNS FROM artists WHERE sourceId = :sourceId AND $ARTIST_SEARCH_FILTER AND $ARTIST_CREDITED " +
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit", "ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
) )
fun searchArtists( fun searchArtists(
sourceId: Long, sourceId: Long,
search: String, search: String,
limit: Int, limit: Int,
sortMode: String = "",
): Flow<List<Artist>> ): Flow<List<Artist>>
@Query( @Query(
@@ -503,12 +656,13 @@ interface LibraryDao {
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " + "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( fun searchSongs(
sourceId: Long, sourceId: Long,
search: String, search: String,
limit: Int, limit: Int,
sortMode: String = "",
): Flow<List<AlbumSongItem>> ): Flow<List<AlbumSongItem>>
@Query( @Query(
@@ -650,36 +804,40 @@ interface LibraryDao {
@RawQuery(observedEntities = [Playlist::class]) @RawQuery(observedEntities = [Playlist::class])
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist> fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE")
fun albumsByName( fun albumsByName(
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0, downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED")
fun albumsByNameReversed( fun albumsByNameReversed(
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0, downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE")
fun albumsByArtist( fun albumsByArtist(
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0, downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED")
fun albumsByArtistReversed( fun albumsByArtistReversed(
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0, downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR") @Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
@@ -762,20 +920,22 @@ interface LibraryDao {
downloadedFilter: Int = 0, downloadedFilter: Int = 0,
): PagingSource<Int, Album> ): PagingSource<Int, Album>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME") @Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE")
fun artistsByName( fun artistsByName(
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0, downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Artist> ): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED") @Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED")
fun artistsByNameReversed( fun artistsByNameReversed(
sourceId: Long, sourceId: Long,
starredFilter: Int, starredFilter: Int,
search: String, search: String,
downloadedFilter: Int = 0, downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Artist> ): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT") @Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
@@ -5,6 +5,7 @@ import androidx.sqlite.SQLiteStatement
import com.subtracks.data.prefs.AlbumSort import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort 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 // 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). // the resulting PagingSource is not invalidated by download writes (including progress updates).
@@ -42,8 +43,9 @@ internal fun albumsDefaultQuery(
search: String, search: String,
sort: AlbumSort, sort: AlbumSort,
descending: Boolean, descending: Boolean,
sortNameMode: SortNameMode = SortNameMode.SortName,
): RoomRawQuery { ): RoomRawQuery {
val order = albumOrder(sort, descending) val order = albumOrder(sort, descending, sortNameMode)
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt -> return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindAlbumFilters(sourceId, starredFilter, search) stmt.bindAlbumFilters(sourceId, starredFilter, search)
} }
@@ -88,8 +90,9 @@ internal fun artistsDefaultQuery(
search: String, search: String,
sort: ArtistSort, sort: ArtistSort,
descending: Boolean, descending: Boolean,
sortNameMode: SortNameMode = SortNameMode.SortName,
): RoomRawQuery { ): RoomRawQuery {
val order = artistOrder(sort, descending) val order = artistOrder(sort, descending, sortNameMode)
return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt -> return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindLong(1, sourceId) stmt.bindLong(1, sourceId)
stmt.bindInt(2, starredFilter) stmt.bindInt(2, starredFilter)
@@ -121,27 +124,82 @@ internal fun playlistsDefaultQuery(
} }
} }
internal fun albumNameSortKey(mode: SortNameMode): String =
when (mode) {
SortNameMode.SortName -> "sortName COLLATE NOCASE"
SortNameMode.Name -> "name COLLATE NOCASE"
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
}
internal fun albumArtistSortKey(mode: SortNameMode): String =
when (mode) {
SortNameMode.SortName -> {
"albumArtistSort COLLATE NOCASE"
}
SortNameMode.Name -> {
"albumArtist COLLATE NOCASE"
}
SortNameMode.NonLatin -> {
"(CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) COLLATE NOCASE"
}
}
internal fun artistNameSortKey(mode: SortNameMode): String =
when (mode) {
SortNameMode.SortName -> "sortName COLLATE NOCASE"
SortNameMode.Name -> "name COLLATE NOCASE"
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
}
private fun albumOrder( private fun albumOrder(
sort: AlbumSort, sort: AlbumSort,
descending: Boolean, descending: Boolean,
mode: SortNameMode,
): String = ): String =
when (sort) { when (sort) {
AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME AlbumSort.Name -> {
AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST val name = albumNameSortKey(mode)
AlbumSort.Year -> if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR if (descending) "$name DESC, id DESC" else "$name, id"
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.Artist -> {
AlbumSort.Recent -> if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT 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( private fun artistOrder(
sort: ArtistSort, sort: ArtistSort,
descending: Boolean, descending: Boolean,
mode: SortNameMode,
): String = ): String =
when (sort) { when (sort) {
ArtistSort.Name -> { 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 -> { ArtistSort.AlbumCount -> {
@@ -695,6 +695,80 @@ private suspend fun createSearchIndex(
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')") 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> = val MIGRATIONS: Array<Migration> =
arrayOf( arrayOf(
MIGRATION_1_2, MIGRATION_1_2,
@@ -731,4 +805,7 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_32_33, MIGRATION_32_33,
MIGRATION_33_34, MIGRATION_33_34,
MIGRATION_34_35, MIGRATION_34_35,
MIGRATION_35_36,
MIGRATION_36_37,
MIGRATION_37_38,
) )
@@ -28,6 +28,7 @@ import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.SongGenre import com.subtracks.data.model.SongGenre
import com.subtracks.data.model.SongSearch import com.subtracks.data.model.SongSearch
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.model.StringListConverter
import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.UpNextEntry import com.subtracks.data.model.UpNextEntry
@@ -56,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
SongDownload::class, SongDownload::class,
PendingAction::class, PendingAction::class,
], ],
version = 35, version = 38,
exportSchema = true, exportSchema = true,
) )
@ColumnTypeConverters( @ColumnTypeConverters(
@@ -65,6 +66,7 @@ import com.subtracks.data.model.UpNextEntry
DownloadErrorConverter::class, DownloadErrorConverter::class,
PendingActionKindConverter::class, PendingActionKindConverter::class,
ArtistRefsConverter::class, ArtistRefsConverter::class,
StringListConverter::class,
) )
abstract class SubtracksDatabase : RoomDatabase() { abstract class SubtracksDatabase : RoomDatabase() {
abstract fun libraryDao(): LibraryDao abstract fun libraryDao(): LibraryDao
@@ -10,3 +10,8 @@ fun coverArtKey(
coverArt: String, coverArt: String,
thumbnail: Boolean, thumbnail: Boolean,
): String = "$sourceId:$coverArt:$thumbnail" ): 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, @ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
) { ) {
val progress: Float? 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 DownloadList { Album, Playlist, Artist }
enum class BulkDownloadAction { Download, Cancel, Delete } enum class BulkDownloadAction { Download, Cancel, Delete }
@@ -99,17 +104,19 @@ data class DownloadedSong(
val songSort: String = title, val songSort: String = title,
) { ) {
val progress: Float? val progress: Float?
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) } get() = fractionOf(bytes, total)
} }
data class DownloadArtwork( data class DownloadArtwork(
val sourceId: Long, val sourceId: Long,
val albumId: String?, val albumId: String?,
val artistId: String?, val artistId: String?,
val albumArtistId: String?,
val albumRow: String?, val albumRow: String?,
val artistRow: String?, val artistRow: String?,
val albumCoverArt: String?, val albumCoverArt: String?,
val artistCoverArt: String?, val artistCoverArt: String?,
val albumArtistCoverArt: String?,
) { ) {
val hasMissingLibraryRow: Boolean val hasMissingLibraryRow: Boolean
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null) get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
@@ -59,6 +59,7 @@ data class ArtistRef(
indices = [ indices = [
Index("sourceId"), Index("sourceId"),
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]), Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
Index(name = "index_artists_name_plain", value = ["sourceId", "name", "id"]),
Index( Index(
name = "index_artists_albumCount", name = "index_artists_albumCount",
value = ["sourceId", "albumCount", "sortName", "id"], value = ["sourceId", "albumCount", "sortName", "id"],
@@ -88,6 +89,9 @@ data class Artist(
val albumCount: Long, val albumCount: Long,
val starred: Long?, val starred: Long?,
val coverArt: String? = null, 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, @ColumnInfo(defaultValue = "0") val playCount: Long = 0,
val played: Long? = null, val played: Long? = null,
@ColumnInfo(defaultValue = "0") val songCount: Long = 0, @ColumnInfo(defaultValue = "0") val songCount: Long = 0,
@@ -128,7 +132,9 @@ data class ArtistBio(
Index("sourceId"), Index("sourceId"),
Index("sourceId", "artistId"), Index("sourceId", "artistId"),
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]), Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
Index(name = "index_albums_name_plain", value = ["sourceId", "name", "id"]),
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]), Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
Index(name = "index_albums_artist_plain", value = ["sourceId", "albumArtist", "year", "name", "id"]),
Index( Index(
name = "index_albums_year", name = "index_albums_year",
value = ["sourceId", "year", "sortName", "id"], value = ["sourceId", "year", "sortName", "id"],
@@ -248,6 +254,8 @@ data class Playlist(
val created: Long, val created: Long,
@ColumnInfo(defaultValue = "0") val changed: Long = 0, @ColumnInfo(defaultValue = "0") val changed: Long = 0,
@ColumnInfo(defaultValue = "0") val duration: Long = 0, @ColumnInfo(defaultValue = "0") val duration: Long = 0,
val owner: String? = null,
@ColumnInfo(defaultValue = "0") val shared: Boolean = false,
) )
@Entity( @Entity(
@@ -592,6 +600,16 @@ class ArtistRefsConverter {
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value) 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 { object ArtistRefsCodec {
fun encode(refs: List<ArtistRef>): String = fun encode(refs: List<ArtistRef>): String =
buildString { buildString {
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
enum class SortNameMode { SortName, Name, NonLatin }
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent } enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent } enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
@@ -182,6 +184,17 @@ class UserPreferences(
private val keepScreenOnKey = booleanPreferencesKey("keep_screen_on") 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 } fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
suspend fun setOfflineMode(enabled: Boolean) { suspend fun setOfflineMode(enabled: Boolean) {
@@ -11,6 +11,7 @@ import com.subtracks.data.download.EngineDownload
import com.subtracks.data.download.EngineRequest import com.subtracks.data.download.EngineRequest
import com.subtracks.data.download.EngineStatus import com.subtracks.data.download.EngineStatus
import com.subtracks.data.download.looksLikeAudio import com.subtracks.data.download.looksLikeAudio
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistBio import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadError import com.subtracks.data.model.DownloadError
@@ -19,6 +20,7 @@ import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.DownloadedSong import com.subtracks.data.model.DownloadedSong
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey import com.subtracks.data.model.coverArtKey
import com.subtracks.log.Log import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -66,6 +68,7 @@ class DownloadRepository(
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap()) private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>() private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
private val failedArtistImages = ConcurrentHashMap.newKeySet<String>()
fun start() { fun start() {
if (started) return if (started) return
@@ -350,6 +353,9 @@ class DownloadRepository(
rows.groupBy { it.sourceId }.forEach { (sourceId, group) -> rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
group.forEach { file(sourceId, it.songId).delete() } group.forEach { file(sourceId, it.songId).delete() }
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) } 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()) sweep(db.downloadDao().all())
} }
@@ -372,6 +378,7 @@ class DownloadRepository(
.mapNotNull { it.engineId } .mapNotNull { it.engineId }
.let(engine::cancel) .let(engine::cancel)
db.downloadDao().deleteSource(sourceId) db.downloadDao().deleteSource(sourceId)
db.libraryDao().clearDownloadedArtistArt(sourceId)
dir(sourceId).deleteRecursively() dir(sourceId).deleteRecursively()
} }
} }
@@ -413,13 +420,14 @@ class DownloadRepository(
) { ) {
val song = db.libraryDao().songOnce(sourceId, songId) val song = db.libraryDao().songOnce(sourceId, songId)
val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) } 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(song?.artistId, album?.artistId).forEach(artistIds::add)
listOfNotNull(songArtist, albumArtist).forEach { storeArtistImage(sourceId, it) }
val coverArt = val coverArt =
( (
listOfNotNull( listOfNotNull(album?.coverArt) +
album?.coverArt, db.downloadDao().playlistCoversForSong(sourceId, songId)
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
).distinct() ).distinct()
coverArt.forEach { cover -> coverArt.forEach { cover ->
storeArtwork(sourceId, cover, thumbnail = false) storeArtwork(sourceId, cover, thumbnail = false)
@@ -427,6 +435,37 @@ 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( private suspend fun fetchArtistBio(
sourceId: Long, sourceId: Long,
artistId: String, artistId: String,
@@ -491,11 +530,15 @@ class DownloadRepository(
.mapValues { (sourceId, rows) -> .mapValues { (sourceId, rows) ->
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
val covers = val covers =
rows.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) } + rows.flatMap { row ->
playlistCovers[sourceId].orEmpty() listOfNotNull(row.albumCoverArt, row.artistCoverArt, row.albumArtistCoverArt)
covers } + playlistCovers[sourceId].orEmpty()
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } } val artistImages = rows.flatMap { row -> listOfNotNull(row.artistId, row.albumArtistId) }
.toSet() (
covers.flatMap { cover ->
listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) }
} + artistImages.map { artistImageKey(sourceId, it) }
).toSet()
} }
} }
@@ -545,6 +588,7 @@ class DownloadRepository(
row.engineId?.let { engine.cancel(listOf(it)) } row.engineId?.let { engine.cancel(listOf(it)) }
file(sourceId, songId).delete() file(sourceId, songId).delete()
db.downloadDao().delete(sourceId, songId) db.downloadDao().delete(sourceId, songId)
if (row.status == DownloadStatus.Completed) refreshDownloadedArtistArt(sourceId, listOf(songId))
return true return true
} }
@@ -625,6 +669,11 @@ class DownloadRepository(
}, },
) )
if (updated != row) db.downloadDao().upsert(updated) 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) { if (updated.status != row.status) {
when (updated.status) { when (updated.status) {
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)") DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
@@ -642,9 +691,22 @@ class DownloadRepository(
row.engineId?.let { engine.cancel(listOf(it)) } row.engineId?.let { engine.cancel(listOf(it)) }
file(row.sourceId, row.songId).delete() file(row.sourceId, row.songId).delete()
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error)) 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") 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() { private fun ensurePolling() {
if (pollJob?.isActive == true) return if (pollJob?.isActive == true) return
pollJob = pollJob =
@@ -24,27 +24,32 @@ import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.StarredFilter import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.source.ServerActionSink import com.subtracks.data.source.ServerActionSink
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.log.Log import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalCoroutinesApi::class)
class LibraryRepository( class LibraryRepository(
private val db: SubtracksDatabase, private val db: SubtracksDatabase,
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val serverActions: ServerActionSink, private val serverActions: ServerActionSink,
private val userPreferences: UserPreferences,
private val showMessage: (UiMessage) -> Unit = {}, private val showMessage: (UiMessage) -> Unit = {},
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) { ) {
@@ -111,12 +116,18 @@ class LibraryRepository(
fun searchAlbums( fun searchAlbums(
sourceId: Long, sourceId: Long,
query: String, 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( fun searchArtists(
sourceId: Long, sourceId: Long,
query: String, 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( fun searchPlaylists(
sourceId: Long, sourceId: Long,
@@ -126,7 +137,10 @@ class LibraryRepository(
fun searchSongs( fun searchSongs(
sourceId: Long, sourceId: Long,
query: String, 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) } fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
@@ -178,66 +192,68 @@ class LibraryRepository(
search: String, search: String,
downloaded: Boolean, downloaded: Boolean,
): Flow<PagingData<Album>> = ): Flow<PagingData<Album>> =
pager(40, enablePlaceholders = true) { userPreferences.sortNameMode().flatMapLatest { mode ->
val dao = db.libraryDao() pager(40, enablePlaceholders = true) {
if (!downloaded) { val dao = db.libraryDao()
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending)) if (!downloaded) {
} else { dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
val downloadedFilter = 1 } else {
when (sort) { val downloadedFilter = 1
AlbumSort.Name -> { when (sort) {
if (descending) { AlbumSort.Name -> {
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter) if (descending) {
} else { dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter) } else {
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
}
} }
}
AlbumSort.Artist -> { AlbumSort.Artist -> {
if (descending) { if (descending) {
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
} else { } else {
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
}
} }
}
AlbumSort.Year -> { AlbumSort.Year -> {
if (descending) { if (descending) {
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
AlbumSort.Added -> { AlbumSort.Added -> {
if (descending) { if (descending) {
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
AlbumSort.Starred -> { AlbumSort.Starred -> {
if (descending) { if (descending) {
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
AlbumSort.Frequent -> { AlbumSort.Frequent -> {
if (descending) { if (descending) {
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
AlbumSort.Recent -> { AlbumSort.Recent -> {
if (descending) { if (descending) {
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter) dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
} }
} }
@@ -252,50 +268,52 @@ class LibraryRepository(
search: String, search: String,
downloaded: Boolean, downloaded: Boolean,
): Flow<PagingData<Artist>> = ): Flow<PagingData<Artist>> =
pager(60) { userPreferences.sortNameMode().flatMapLatest { mode ->
val dao = db.libraryDao() pager(60) {
if (!downloaded) { val dao = db.libraryDao()
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending)) if (!downloaded) {
} else { dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
val downloadedFilter = 1 } else {
when (sort) { val downloadedFilter = 1
ArtistSort.Name -> { when (sort) {
if (descending) { ArtistSort.Name -> {
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter) if (descending) {
} else { dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter) } else {
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
}
} }
}
ArtistSort.AlbumCount -> { ArtistSort.AlbumCount -> {
if (descending) { if (descending) {
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
ArtistSort.Starred -> { ArtistSort.Starred -> {
if (descending) { if (descending) {
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
ArtistSort.Frequent -> { ArtistSort.Frequent -> {
if (descending) { if (descending) {
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
}
ArtistSort.Recent -> { ArtistSort.Recent -> {
if (descending) { if (descending) {
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else { } else {
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter) dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
}
} }
} }
} }
@@ -424,6 +442,11 @@ class LibraryRepository(
} }
} }
fun artistCoverArts(
sourceId: Long,
artistId: String,
): Flow<List<String>> = db.libraryDao().artistCoverArts(sourceId, artistId)
fun albumDiscs( fun albumDiscs(
sourceId: Long, sourceId: Long,
albumId: String, albumId: String,
@@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicConfig import com.subtracks.data.model.SubsonicConfig
import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey import com.subtracks.data.model.coverArtKey
import com.subtracks.data.net.NetworkMode import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.StreamQuality
@@ -69,6 +70,9 @@ class SourceRepository(
private val _offline = MutableStateFlow(false) private val _offline = MutableStateFlow(false)
val offline: StateFlow<Boolean> = _offline val offline: StateFlow<Boolean> = _offline
private val _activeSource = MutableStateFlow<Long?>(null)
val activeSource: StateFlow<Long?> = _activeSource
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
fun setOfflineMode(enabled: Boolean) { fun setOfflineMode(enabled: Boolean) {
@@ -109,6 +113,7 @@ class SourceRepository(
Log.i("net", "active source -> ${config?.name ?: "none"}") Log.i("net", "active source -> ${config?.name ?: "none"}")
} }
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) } active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
_activeSource.value = config?.id
_quality.value = quality _quality.value = quality
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) { if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
scope.launch { scope.launch {
@@ -143,6 +148,18 @@ class SourceRepository(
return CoverArtRef(url = url, cacheKey = cacheKey) 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( fun networkCoverArt(
sourceId: Long, sourceId: Long,
coverArt: String, coverArt: String,
@@ -32,6 +32,7 @@ object SubsonicXml {
albumCount = attrs.longAttr("albumCount") ?: 0L, albumCount = attrs.longAttr("albumCount") ?: 0L,
starred = attrs.dateAttr("starred"), starred = attrs.dateAttr("starred"),
coverArt = attrs.attr("coverArt").ifEmpty { null }, coverArt = attrs.attr("coverArt").ifEmpty { null },
artistImageUrl = attrs.attr("artistImageUrl").ifEmpty { null },
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"), sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
) )
@@ -128,6 +129,8 @@ object SubsonicXml {
created = attrs.dateAttr("created") ?: 0L, created = attrs.dateAttr("created") ?: 0L,
changed = attrs.dateAttr("changed") ?: 0L, changed = attrs.dateAttr("changed") ?: 0L,
duration = attrs.longAttr("duration") ?: 0L, duration = attrs.longAttr("duration") ?: 0L,
owner = attrs.attr("owner").ifEmpty { null },
shared = attrs.boolAttr("public"),
) )
internal fun playlistSong( internal fun playlistSong(
@@ -364,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.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.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) internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
@@ -438,7 +438,7 @@ private fun discHash(
private val primaryKey = listOf("sourceId", "id") private val primaryKey = listOf("sourceId", "id")
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "sortName") private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "artistImageUrl", "sortName")
private val albumColumns = private val albumColumns =
listOf( listOf(
@@ -487,7 +487,8 @@ private val songColumns =
"artistSort", "artistSort",
) )
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration") private val playlistColumns =
listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration", "owner", "shared")
private fun Song.genreNames(): List<String> = private fun Song.genreNames(): List<String> =
genres genres
@@ -515,7 +516,7 @@ private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "po
private val playlistSongKey = listOf("sourceId", "playlistId", "position") private val playlistSongKey = listOf("sourceId", "playlistId", "position")
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, sortName) private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, artistImageUrl, sortName)
private fun Album.values() = private fun Album.values() =
listOf<Any?>( listOf<Any?>(
@@ -577,7 +578,7 @@ private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.pos
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre) private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration) private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration, owner, shared)
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position) private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
@@ -96,7 +96,7 @@ fun appModule(
scope = get(), 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<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) } single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
single { single {
@@ -12,6 +12,8 @@ import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController import androidx.media3.session.MediaController
import com.subtracks.R import com.subtracks.R
import com.subtracks.UiMessage 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.model.AudioEncoding
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
@@ -229,6 +231,7 @@ class Media3PlayerHandle(
.orEmpty(), .orEmpty(),
artist = mediaItem.mediaMetadata.artist?.toString(), artist = mediaItem.mediaMetadata.artist?.toString(),
album = mediaItem.mediaMetadata.albumTitle?.toString(), album = mediaItem.mediaMetadata.albumTitle?.toString(),
artists = mediaItem.artistRefs(),
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID), coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
durationMs = durationMs =
mediaItem.mediaMetadata.extras mediaItem.mediaMetadata.extras
@@ -238,7 +241,14 @@ 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_COVER_ART_ID = "coverArtId"
private const val EXTRA_ARTISTS = "artists"
private const val EXTRA_DURATION_MS = "durationMs" private const val EXTRA_DURATION_MS = "durationMs"
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain" private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain" private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
@@ -286,6 +296,7 @@ internal fun queueMediaItem(
.setExtras( .setExtras(
Bundle().apply { Bundle().apply {
putString(EXTRA_COVER_ART_ID, item.coverArtId) putString(EXTRA_COVER_ART_ID, item.coverArtId)
putString(EXTRA_ARTISTS, ArtistRefsCodec.encode(item.artists))
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) } item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain) putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain) putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
@@ -1,6 +1,7 @@
package com.subtracks.playback package com.subtracks.playback
import com.subtracks.UiMessage import com.subtracks.UiMessage
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.AudioEncoding import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueEntry
@@ -36,6 +37,7 @@ data class QueueItem(
val durationMs: Long? = null, val durationMs: Long? = null,
val replayGain: ReplayGain = ReplayGain(), val replayGain: ReplayGain = ReplayGain(),
val albumContext: Boolean = false, val albumContext: Boolean = false,
val artists: List<ArtistRef> = emptyList(),
) )
data class QueueContext( data class QueueContext(
@@ -1,5 +1,6 @@
package com.subtracks.playback package com.subtracks.playback
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.SongItem import com.subtracks.data.model.SongItem
fun SongItem.toQueueItem(albumContext: Boolean = false) = fun SongItem.toQueueItem(albumContext: Boolean = false) =
@@ -7,6 +8,7 @@ fun SongItem.toQueueItem(albumContext: Boolean = false) =
id = song.id, id = song.id,
title = song.title, title = song.title,
artist = song.artist, artist = song.artist,
artists = song.artistRefs,
album = song.album, album = song.album,
coverArtId = coverArt, coverArtId = coverArt,
durationMs = song.duration?.let { it * 1000 }, durationMs = song.duration?.let { it * 1000 },
+122 -20
View File
@@ -61,14 +61,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.subtracks.R import com.subtracks.R
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController 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.ContextMenuHost
import com.subtracks.ui.components.ItemContextMenu import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.LoadingState 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.SongInfoDialog
import com.subtracks.ui.components.statusBarScrim import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.downloads.DownloadsRoute import com.subtracks.ui.downloads.DownloadsRoute
@@ -230,7 +236,8 @@ private fun MainNavigation() {
var settleJob by remember { mutableStateOf<Job?>(null) } var settleJob by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val contextMenuHost = remember { ContextMenuHost() } 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) val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null)
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) } var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
@@ -312,7 +319,8 @@ private fun MainNavigation() {
nowPlayingLauncher.openNowPlaying.collect { nowPlayingLauncher.openNowPlaying.collect {
focusManager.clearFocus() focusManager.clearFocus()
contextMenuHost.dismissTransients() contextMenuHost.dismissTransients()
infoSong = null infoTarget = null
artistChooser = null
showingQueue = false showingQueue = false
if (navController.currentDestination?.route in DETAIL_ROUTES) { if (navController.currentDestination?.route in DETAIL_ROUTES) {
navController.popBackStack(Routes.LIBRARY, inclusive = false) 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)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
}, },
onArtistClick = { artist -> 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 -> onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id)) 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)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
}, },
onArtistClick = { artist -> 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 -> onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id)) 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)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
}, },
onArtistClick = { artist -> 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)) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId -> onViewArtist = { artistId ->
@@ -538,7 +570,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
}, },
onArtistClick = { artist -> 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)) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId -> onViewArtist = { artistId ->
@@ -562,7 +602,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
}, },
onArtistClick = { artist -> 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)) }, onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId -> onViewArtist = { artistId ->
@@ -588,7 +636,15 @@ private fun MainNavigation() {
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt)) navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
}, },
onArtistClick = { artist -> 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 -> onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id)) navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
@@ -725,23 +781,69 @@ private fun MainNavigation() {
target = target, target = target,
actions = contextMenuHost.actions, actions = contextMenuHost.actions,
onDismiss = { contextMenuHost.dismiss() }, onDismiss = { contextMenuHost.dismiss() },
onInfo = { infoSong = it }, onInfo = { infoTarget = it },
onChooseArtists = { artistChooser = it },
) )
} }
infoSong?.let { song -> artistChooser?.let { artists ->
val genres by ArtistChooserSheet(
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) } artists = artists,
.collectAsStateWithLifecycle(initialValue = emptyList()) onArtistClick = { artist ->
SongInfoDialog( artistChooser = null
song = song, navigateDetail(
download = downloads[song.id], Routes.ARTIST_DETAIL,
localFile = downloadRepository.localFile(song.id), "artistId",
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id }, artist.id,
onDismiss = { infoSong = null }, Routes.artist(artist.id),
genres = genres, )
},
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)
}
}
@@ -85,12 +85,15 @@ fun CoverArt(
square: Boolean = true, square: Boolean = true,
elevation: Dp = 0.dp, elevation: Dp = 0.dp,
colorFilter: ColorFilter? = null, colorFilter: ColorFilter? = null,
onError: (() -> Unit)? = null,
) { ) {
val context = LocalPlatformContext.current val context = LocalPlatformContext.current
val refKey = ref?.cacheKey val refKey = ref?.cacheKey
val thumbnailKey = thumbnailRef?.cacheKey val thumbnailKey = thumbnailRef?.cacheKey
val hasThumbnailFallback = thumbnailRef != null && thumbnailRef != ref
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) } var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
var thumbnailLoaded 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)) } var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
val letterScheme = val letterScheme =
if (showPlaceholder || (failed && !thumbnailLoaded)) { if (showPlaceholder || (failed && !thumbnailLoaded)) {
@@ -115,6 +118,10 @@ fun CoverArt(
thumbnailLoaded = thumbnailLoaded, thumbnailLoaded = thumbnailLoaded,
onThumbnailLoaded = { thumbnailLoaded = true }, onThumbnailLoaded = { thumbnailLoaded = true },
onThumbnailRatio = { thumbnailRatio = it }, onThumbnailRatio = { thumbnailRatio = it },
onThumbnailError = {
thumbnailFailed = true
if (failed) onError?.invoke()
},
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
colorFilter = colorFilter, colorFilter = colorFilter,
placeholderContentColor = letterScheme?.onPrimaryContainer, placeholderContentColor = letterScheme?.onPrimaryContainer,
@@ -131,7 +138,10 @@ fun CoverArt(
.ratioOrNull() .ratioOrNull()
?.let { ArtworkRatioCache.put(ref.cacheKey, it) } ?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
}, },
onError = { failed = true }, onError = {
failed = true
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
},
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@@ -160,7 +170,10 @@ fun CoverArt(
) )
} }
}, },
onError = { failed = true }, onError = {
failed = true
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
},
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
filterQuality = FilterQuality.High, filterQuality = FilterQuality.High,
) )
@@ -179,6 +192,10 @@ fun CoverArt(
thumbnailLoaded = thumbnailLoaded, thumbnailLoaded = thumbnailLoaded,
onThumbnailLoaded = { thumbnailLoaded = true }, onThumbnailLoaded = { thumbnailLoaded = true },
onThumbnailRatio = { thumbnailRatio = it }, onThumbnailRatio = { thumbnailRatio = it },
onThumbnailError = {
thumbnailFailed = true
if (failed) onError?.invoke()
},
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
colorFilter = colorFilter, colorFilter = colorFilter,
placeholderContentColor = letterScheme?.onPrimaryContainer, placeholderContentColor = letterScheme?.onPrimaryContainer,
@@ -208,6 +225,7 @@ private fun BoxScope.CoverArtContent(
thumbnailLoaded: Boolean, thumbnailLoaded: Boolean,
onThumbnailLoaded: () -> Unit, onThumbnailLoaded: () -> Unit,
onThumbnailRatio: (Float) -> Unit, onThumbnailRatio: (Float) -> Unit,
onThumbnailError: () -> Unit,
contentScale: ContentScale, contentScale: ContentScale,
colorFilter: ColorFilter?, colorFilter: ColorFilter?,
placeholderContentColor: Color?, placeholderContentColor: Color?,
@@ -246,6 +264,7 @@ private fun BoxScope.CoverArtContent(
ArtworkRatioCache.put(thumbnailRef.cacheKey, it) ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
} }
}, },
onError = { onThumbnailError() },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -47,12 +48,17 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -98,12 +104,13 @@ fun HeroDetailScaffold(
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit, header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
content: LazyListScope.(rowModifier: Modifier) -> Unit, content: LazyListScope.(rowModifier: Modifier) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentReady: Boolean = true,
) { ) {
val effectiveArtwork = artwork ?: baseArtworkColors() val effectiveArtwork = artwork ?: baseArtworkColors()
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(effectiveArtwork) { ArtworkTheme(effectiveArtwork) {
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
val listState = rememberLazyListState() val listState = rememberRestorableLazyListState(contentReady)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val fill = rememberViewportFill(listState) val fill = rememberViewportFill(listState)
val density = LocalDensity.current val density = LocalDensity.current
@@ -114,9 +121,9 @@ fun HeroDetailScaffold(
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() } val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() } val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
var headerHeightPx by remember { mutableFloatStateOf(0f) } var headerHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
var rowHeightPx by remember { mutableFloatStateOf(0f) } var rowHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
var controlsTopPx by remember { mutableFloatStateOf(0f) } var controlsTopPx by rememberSaveable { mutableFloatStateOf(0f) }
val scrollPx by remember { val scrollPx by remember {
derivedStateOf { derivedStateOf {
val index = listState.firstVisibleItemIndex 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 @Composable
private fun HeroSubtitle( private fun HeroSubtitle(
text: String, text: String,
@@ -291,7 +323,11 @@ fun HeroHeader(
modifier = modifier =
Modifier Modifier
.heightIn(min = 32.dp) .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, contentAlignment = Alignment.TopCenter,
) { ) {
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp)) 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.automirrored.rounded.QueueMusic
import androidx.compose.material.icons.rounded.Album import androidx.compose.material.icons.rounded.Album
import androidx.compose.material.icons.rounded.Cancel 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.subtracks.R import com.subtracks.R
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadList
@@ -197,7 +199,8 @@ fun ItemContextMenu(
target: MenuTarget, target: MenuTarget,
actions: ItemActions, actions: ItemActions,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onInfo: ((SongModel) -> Unit)? = null, onInfo: ((MenuTarget) -> Unit)? = null,
onChooseArtists: ((List<ArtistRef>) -> Unit)? = null,
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -210,6 +213,11 @@ fun ItemContextMenu(
} }
} }
fun replaceWith(after: () -> Unit) {
onDismiss()
after()
}
val queueRef = target.queueRef() val queueRef = target.queueRef()
@Composable @Composable
@@ -256,18 +264,9 @@ fun ItemContextMenu(
} }
} }
} }
target.song.artistRefs.forEach { artist -> ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
actions.viewArtist?.let { view ->
MenuItem(
Icons.Rounded.Person,
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artist.id) }
}
}
}
onInfo?.let { info -> 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) }, onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
onDismiss = { dismiss() }, onDismiss = { dismiss() },
) )
target.album.artistRefs.forEach { artist -> ArtistItems(target.album.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
actions.viewArtist?.let { view -> onInfo?.let { info ->
MenuItem( MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
Icons.Rounded.Person,
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
) {
dismiss { view(artist.id) }
}
}
} }
} }
@@ -298,11 +291,17 @@ fun ItemContextMenu(
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) }, onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
onDismiss = { dismiss() }, onDismiss = { dismiss() },
) )
onInfo?.let { info ->
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
}
} }
is MenuTarget.Playlist -> { is MenuTarget.Playlist -> {
queueItems() queueItems()
BulkDownloadItem(target, actions, ::dismiss) BulkDownloadItem(target, actions, ::dismiss)
onInfo?.let { info ->
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
}
} }
} }
Spacer(Modifier.height(16.dp)) 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 @Composable
private fun DownloadItem( private fun DownloadItem(
download: SongDownload?, download: SongDownload?,
@@ -439,11 +487,19 @@ private fun MenuItem(
icon: ImageVector, icon: ImageVector,
label: String, label: String,
tint: Color? = null, tint: Color? = null,
trailing: (@Composable () -> Unit)? = null,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
ListItem( ListItem(
headlineContent = { Text(label) }, headlineContent = {
Text(
text = label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) }, leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
trailingContent = trailing,
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable(onClick = onClick), 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
@@ -1,12 +1,28 @@
package com.subtracks.ui.components package com.subtracks.ui.components
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
private const val ARTIST_SEPARATOR = " \u00B7 " private const val ARTIST_SEPARATOR = " \u00B7 "
internal fun Song.artistSubtitle(): String? { 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() val names = artists.map { it.name }.filter { it.isNotBlank() }.distinct()
if (names.isEmpty()) return artist?.takeIf { it.isNotBlank() } 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 } val primary = artist?.takeIf { it in names }
return buildList { return buildList {
primary?.let(::add) primary?.let(::add)
@@ -4,12 +4,7 @@ import android.text.format.DateUtils
import android.text.format.Formatter import android.text.format.Formatter
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -18,8 +13,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -53,8 +46,20 @@ fun SongInfoDialog(
title = { Text(stringResource(R.string.info)) }, title = { Text(stringResource(R.string.info)) },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { 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_sort_by_title), song.title)
InfoRow(stringResource(R.string.resources_filter_artist), song.artistSubtitle()) 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.resources_filter_album), song.album)
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime)) InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
InfoRow( 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 @Composable
private fun downloadStatus(status: DownloadStatus): String = private fun downloadStatus(status: DownloadStatus): String =
when (status) { when (status) {
@@ -57,6 +57,8 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController 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.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
@@ -108,6 +110,7 @@ fun HomeListRoute(
genres = genres, genres = genres,
decades = decades, decades = decades,
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id, playingSongId = playback.item?.id,
onBack = onBack, onBack = onBack,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
@@ -133,6 +136,7 @@ fun HomeListScreen(
genres: List<String> = emptyList(), genres: List<String> = emptyList(),
decades: List<Long> = emptyList(), decades: List<Long> = emptyList(),
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null }, coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
playingSongId: String? = null, playingSongId: String? = null,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onAlbumClick: (Album) -> Unit = {}, onAlbumClick: (Album) -> Unit = {},
@@ -191,7 +195,7 @@ fun HomeListScreen(
OfflineListKind.Artists -> { OfflineListKind.Artists -> {
ArtistsList( ArtistsList(
artists = artists, artists = artists,
coverArt = coverArt, artistArt = artistArt,
info = { artistListInfo(request, it, now, resources) }, info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onLongClick = onLongClick, onLongClick = onLongClick,
@@ -245,7 +249,7 @@ fun HomeListScreen(
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> { HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
ArtistsList( ArtistsList(
artists = artists, artists = artists,
coverArt = coverArt, artistArt = artistArt,
info = { artistListInfo(request, it, now, resources) }, info = { artistListInfo(request, it, now, resources) },
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onLongClick = onLongClick, onLongClick = onLongClick,
@@ -443,7 +447,7 @@ internal fun AlbumListRow(
@Composable @Composable
private fun ArtistsList( private fun ArtistsList(
artists: LazyPagingItems<Artist>, artists: LazyPagingItems<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?, artistArt: (Artist) -> ArtistArt,
info: (Artist) -> String?, info: (Artist) -> String?,
onArtistClick: (Artist) -> Unit, onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit, onLongClick: (MenuTarget) -> Unit,
@@ -460,12 +464,13 @@ private fun ArtistsList(
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index -> items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
val artist = artists[index] val artist = artists[index]
if (artist != null) { if (artist != null) {
val art = artistArt(artist)
ArtistListRow( ArtistListRow(
artist = artist, artist = artist,
coverArt = coverArt, art = art,
info = info(artist), info = info(artist),
onClick = { onArtistClick(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 @Composable
internal fun ArtistListRow( internal fun ArtistListRow(
artist: Artist, artist: Artist,
coverArt: (String?, Boolean) -> CoverArtRef?, art: ArtistArt,
info: String?, info: String?,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
@@ -488,9 +493,9 @@ internal fun ArtistListRow(
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
CoverArt( ArtistIcon(
ref = coverArt(artist.coverArt, true), artist = artist,
name = artist.name, art = art,
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape), modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
) )
Spacer(Modifier.width(12.dp)) 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.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -156,6 +158,11 @@ class HomeListViewModel(
thumbnail: Boolean, thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun play(song: Song) { fun play(song: Song) {
val genre = request.genre val genre = request.genre
when { when {
@@ -72,6 +72,8 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController 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.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
@@ -137,6 +139,7 @@ fun HomeRoute(
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(), playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value, songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id, playingSongId = playback.item?.id,
topInset = topInset, topInset = topInset,
bottomInset = bottomInset, bottomInset = bottomInset,
@@ -152,6 +155,7 @@ fun HomeRoute(
HomeScreen( HomeScreen(
feed = feed, feed = feed,
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id, playingSongId = playback.item?.id,
topInset = topInset, topInset = topInset,
bottomInset = bottomInset, bottomInset = bottomInset,
@@ -173,6 +177,7 @@ fun HomeScreen(
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
playingSongId: String?, playingSongId: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
topInset: Dp = 0.dp, topInset: Dp = 0.dp,
bottomInset: Dp = 0.dp, bottomInset: Dp = 0.dp,
onAlbumClick: (Album) -> Unit = {}, onAlbumClick: (Album) -> Unit = {},
@@ -215,6 +220,7 @@ fun HomeScreen(
HomeSection.RecentlyPlayedArtists, HomeSection.RecentlyPlayedArtists,
feed.recentlyPlayedArtists, feed.recentlyPlayedArtists,
coverArt, coverArt,
artistArt,
onArtistClick, onArtistClick,
onLongClick, onLongClick,
onMore, onMore,
@@ -249,6 +255,7 @@ fun HomeScreen(
HomeSection.MostPlayedArtists, HomeSection.MostPlayedArtists,
feed.mostPlayedArtists, feed.mostPlayedArtists,
coverArt, coverArt,
artistArt,
onArtistClick, onArtistClick,
onLongClick, onLongClick,
onMore, onMore,
@@ -296,6 +303,7 @@ fun OfflineHomeScreen(
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
playingSongId: String?, playingSongId: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
topInset: Dp = 0.dp, topInset: Dp = 0.dp,
bottomInset: Dp = 0.dp, bottomInset: Dp = 0.dp,
onAlbumClick: (Album) -> Unit = {}, onAlbumClick: (Album) -> Unit = {},
@@ -312,7 +320,7 @@ fun OfflineHomeScreen(
) { ) {
homeTitle() homeTitle()
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) } 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) } downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
songList( songList(
key = "downloaded-songs", key = "downloaded-songs",
@@ -364,7 +372,7 @@ private fun LazyListScope.downloadedAlbumRow(
private fun LazyListScope.downloadedArtistRow( private fun LazyListScope.downloadedArtistRow(
artists: LazyPagingItems<Artist>, artists: LazyPagingItems<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?, artistArt: (Artist) -> ArtistArt,
onArtistClick: (Artist) -> Unit, onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit, onLongClick: (MenuTarget) -> Unit,
onMore: () -> Unit, onMore: () -> Unit,
@@ -382,12 +390,12 @@ private fun LazyListScope.downloadedArtistRow(
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index -> items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
val artist = artists[index] val artist = artists[index]
if (artist != null) { if (artist != null) {
val ref = coverArt(artist.coverArt, true) val art = artistArt(artist)
ArtistTile( ArtistTile(
artist = artist, artist = artist,
ref = ref, art = art,
onClick = { onArtistClick(artist) }, 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, section: HomeSection,
artists: List<Artist>, artists: List<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
artistArt: (Artist) -> ArtistArt,
onArtistClick: (Artist) -> Unit, onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit, onLongClick: (MenuTarget) -> Unit,
onMore: (HomeSection) -> Unit, onMore: (HomeSection) -> Unit,
@@ -625,12 +634,12 @@ private fun LazyListScope.artistRow(
modifier = Modifier.padding(top = SECTION_CONTENT_TOP), modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
) { ) {
items(artists, key = { it.id }) { artist -> items(artists, key = { it.id }) { artist ->
val ref = coverArt(artist.coverArt, true) val art = artistArt(artist)
ArtistTile( ArtistTile(
artist = artist, artist = artist,
ref = ref, art = art,
onClick = { onArtistClick(artist) }, onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) }, onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
) )
} }
} }
@@ -779,7 +788,7 @@ private fun AlbumTile(
@Composable @Composable
private fun ArtistTile( private fun ArtistTile(
artist: Artist, artist: Artist,
ref: CoverArtRef?, art: ArtistArt,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
) { ) {
@@ -787,9 +796,9 @@ private fun ArtistTile(
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick), modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
CoverArt( ArtistIcon(
ref = ref, artist = artist,
name = artist.name, art = art,
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape), modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
) )
Spacer(Modifier.height(6.dp)) 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.SyncManager
import com.subtracks.data.sync.SyncStatus import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -127,6 +129,11 @@ class HomeViewModel(
thumbnail: Boolean, thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun playStarred(song: Song) { fun playStarred(song: Song) {
viewModelScope.playStarredList(libraryRepository, playbackController, song) viewModelScope.playStarredList(libraryRepository, playbackController, song)
} }
@@ -67,9 +67,9 @@ fun AlbumDetailRoute(
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) }, viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
) { ) {
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null) val album by viewModel.album.collectAsStateWithLifecycle()
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList()) val songs by viewModel.songs.collectAsStateWithLifecycle()
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList()) val discs by viewModel.discs.collectAsStateWithLifecycle()
val downloads by viewModel.downloads.collectAsStateWithLifecycle() val downloads by viewModel.downloads.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle()
@@ -215,6 +215,7 @@ fun AlbumDetailScreen(
artwork = artwork, artwork = artwork,
title = album?.name.orEmpty(), title = album?.name.orEmpty(),
onBack = onBack, onBack = onBack,
contentReady = songs.isNotEmpty(),
header = { controlsModifier, topInset -> header = { controlsModifier, topInset ->
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() } val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
HeroHeader( HeroHeader(
@@ -15,7 +15,6 @@ import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -35,13 +34,20 @@ class AlbumDetailViewModel(
) : ViewModel() { ) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull() 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 } combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, 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() 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.WindowInsets
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan 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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
@@ -54,18 +56,25 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.Shadow
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
@@ -82,7 +91,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -95,6 +106,8 @@ import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController 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.CloseWhenDownloadsGone
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
@@ -121,7 +134,11 @@ import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import kotlin.math.PI
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
private val ART_HEIGHT = 420.dp private val ART_HEIGHT = 420.dp
private val TITLE_INSET = 16.dp private val TITLE_INSET = 16.dp
@@ -134,8 +151,10 @@ private val SHUFFLE_RESERVE = 86.dp
private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2 private val SHUFFLE_HANG = SHUFFLE_HEIGHT / 2
private val BIO_ACTION_RESERVE = 104.dp private val BIO_ACTION_RESERVE = 104.dp
private val BIO_ACTION_HEIGHT = 48.dp private val BIO_ACTION_HEIGHT = 48.dp
private val BIO_ACTION_OFFSET = 8.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_TOP_GAP = 8.dp
private val BIO_BOTTOM_GAP = 8.dp
internal fun estimateScrollPx( internal fun estimateScrollPx(
index: Int, index: Int,
@@ -176,9 +195,9 @@ fun ArtistDetailRoute(
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) }, viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
) { ) {
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null) val artist by viewModel.artist.collectAsStateWithLifecycle()
val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null) val artistName by viewModel.artistName.collectAsStateWithLifecycle()
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = null) val albums by viewModel.albums.collectAsStateWithLifecycle()
val bioSnapshots = val bioSnapshots =
remember(viewModel) { remember(viewModel) {
viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) } viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) }
@@ -193,6 +212,7 @@ fun ArtistDetailRoute(
LaunchedEffect(artistId) { viewModel.refreshBio() } LaunchedEffect(artistId) { viewModel.refreshBio() }
val art by viewModel.art.collectAsStateWithLifecycle() val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle() val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val heroArt by viewModel.heroArt.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle()
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack) CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
@@ -232,6 +252,7 @@ fun ArtistDetailRoute(
bioExpanded = bioExpanded, bioExpanded = bioExpanded,
art = art, art = art,
artThumbnail = artThumbnail, artThumbnail = artThumbnail,
heroArt = heroArt,
artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName), artwork = rememberArtworkColors(shortcutArt ?: artThumbnail ?: art, THEME_TRANSITION_MS, fallbackName = artistName),
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
onBack = onBack, onBack = onBack,
@@ -271,6 +292,7 @@ fun ArtistDetailScreen(
bioExpanded: Boolean = false, bioExpanded: Boolean = false,
art: CoverArtRef?, art: CoverArtRef?,
artThumbnail: CoverArtRef? = null, artThumbnail: CoverArtRef? = null,
heroArt: ArtistArt? = null,
artwork: ArtworkColors? = null, artwork: ArtworkColors? = null,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit, onBack: () -> Unit,
@@ -284,7 +306,7 @@ fun ArtistDetailScreen(
onCollapseBio: () -> Unit = {}, onCollapseBio: () -> Unit = {},
onShuffle: () -> Unit = {}, onShuffle: () -> Unit = {},
) { ) {
val listState = rememberLazyGridState() val listState = rememberRestorableLazyGridState(albumsLoaded)
val fill = rememberViewportFill(listState) val fill = rememberViewportFill(listState)
val density = LocalDensity.current val density = LocalDensity.current
val lineHeight = val lineHeight =
@@ -331,8 +353,8 @@ fun ArtistDetailScreen(
} }
} }
} }
var imageHeightPx by remember { mutableFloatStateOf(0f) } var imageHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
var albumHeightPx by remember { mutableFloatStateOf(0f) } var albumHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
val albumRowGapPx = with(density) { ROW_GAP.toPx() } val albumRowGapPx = with(density) { ROW_GAP.toPx() }
val scrollPx by remember { val scrollPx by remember {
derivedStateOf { derivedStateOf {
@@ -388,11 +410,9 @@ fun ArtistDetailScreen(
.onSizeChanged { imageHeightPx = it.height.toFloat() }, .onSizeChanged { imageHeightPx = it.height.toFloat() },
) { ) {
Box(Modifier.fillMaxWidth()) { Box(Modifier.fillMaxWidth()) {
CoverArt( ArtistHeroArt(
ref = art, art = heroArt ?: ArtistArt(image = art, thumbnail = artThumbnail),
name = displayName, name = displayName,
thumbnailRef = artThumbnail,
showPlaceholder = art == null,
modifier = Modifier.fillMaxWidth().height(ART_HEIGHT), modifier = Modifier.fillMaxWidth().height(ART_HEIGHT),
) )
if (nameScrimAlpha > 0f) { if (nameScrimAlpha > 0f) {
@@ -470,7 +490,7 @@ fun ArtistDetailScreen(
onCollapse = onCollapseBio, onCollapse = onCollapseBio,
modifier = modifier =
Modifier.padding( Modifier.padding(
bottom = if (hasBio && !bioCollapsed) 8.dp else 0.dp, bottom = if (hasBio) BIO_BOTTOM_GAP else 0.dp,
), ),
) )
} }
@@ -554,8 +574,32 @@ fun ArtistDetailScreen(
} }
} }
// The expanded layout narrows only the lines that overlap the action buttons; later lines use the @Composable
// full width. 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 @Composable
private fun ArtistBio( private fun ArtistBio(
text: String, text: String,
@@ -587,10 +631,28 @@ private fun ArtistBio(
.toInt() .toInt()
.coerceAtLeast(1) .coerceAtLeast(1)
val bioLines = val bioLines =
remember(text, fullWidth, reserve, reserveLines, collapsed, expanded, moreText, lessText) { remember(
text,
fullWidth,
reserve,
reserveLines,
collapsed,
expanded,
moreText,
lessText,
) {
when { when {
collapsed -> { collapsed -> {
splitBioCollapsed(measurer, text, style, fullWidth, reserve, reserveLines, moreText, maxLines = 3) splitBioCollapsed(
measurer,
text,
style,
fullWidth,
reserve,
reserveLines,
moreText,
maxLines = 3,
)
} }
expanded -> { expanded -> {
@@ -626,7 +688,11 @@ private fun ArtistBio(
) )
} }
} }
Row(Modifier.align(Alignment.TopEnd).offset(y = -BIO_ACTION_OFFSET)) { Row(
Modifier
.align(Alignment.TopEnd)
.offset(x = BIO_ACTION_END_OFFSET, y = -BIO_ACTION_OFFSET),
) {
ArtistActions(artist, starred, onToggleStar, onMore) ArtistActions(artist, starred, onToggleStar, onMore)
} }
} }
@@ -13,10 +13,10 @@ import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -25,8 +25,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -40,23 +38,26 @@ class ArtistDetailViewModel(
) : ViewModel() { ) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull() 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?> = val artistName: StateFlow<String?> =
sourceId.flatMapLatest { id -> sourceId
combine( .flatMapLatest { id ->
libraryRepository.artist(id, artistId), combine(
libraryRepository.artistName(id, artistId), libraryRepository.artist(id, artistId),
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs } 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 offline: StateFlow<Boolean> = sourceRepository.offline
val albums: Flow<ArtistAlbums?> = val albums: StateFlow<ArtistAlbums?> =
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline } combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) } .flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
.map<ArtistAlbums, ArtistAlbums?> { it } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
.onStart { emit(null) }
val downloadStatus: StateFlow<ListDownloadStatus> = val downloadStatus: StateFlow<ListDownloadStatus> =
sourceId sourceId
@@ -68,7 +69,10 @@ class ArtistDetailViewModel(
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) } .flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
val artistBio: Flow<ArtistBio?> = sourceId.flatMapLatest { libraryRepository.artistBio(it, artistId) } val artistBio: StateFlow<ArtistBio?> =
sourceId
.flatMapLatest { libraryRepository.artistBio(it, artistId) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
private val _bioLoading = MutableStateFlow(true) private val _bioLoading = MutableStateFlow(true)
val bioLoading: StateFlow<Boolean> = _bioLoading val bioLoading: StateFlow<Boolean> = _bioLoading
@@ -136,30 +140,36 @@ class ArtistDetailViewModel(
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null) private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
private val _heroArt = MutableStateFlow<ArtistArt?>(null)
val heroArt: StateFlow<ArtistArt?> = _heroArt
init { init {
viewModelScope.launch { viewModelScope.launch {
artist sourceRepository.activeSource
.filterNotNull() .filterNotNull()
.collectLatest { loaded -> .flatMapLatest { sourceId ->
val (full, thumbnail) = resolveArt(loaded) combine(
_art.value = full libraryRepository.artist(sourceId, artistId),
_artThumbnail.value = thumbnail 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( fun coverArt(
coverArt: String?, coverArt: String?,
thumbnail: Boolean, thumbnail: Boolean,
@@ -171,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.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus 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.EmptyState
import com.subtracks.ui.components.FilteredEmptyState import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.ListDownloadIndicator import com.subtracks.ui.components.ListDownloadIndicator
@@ -55,6 +56,7 @@ fun ArtistsContent(
onSync: () -> Unit = {}, onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(), downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
scrollToTopKey: Any? = null, scrollToTopKey: Any? = null,
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
) { ) {
when { when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { 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 -> items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
val artist = items[index] val artist = items[index]
if (artist != null) { if (artist != null) {
PrefetchArtworkSeeds(coverArt(artist.coverArt, true)) val art = artistArt(artist)
PrefetchArtworkSeeds(art.primary)
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -104,9 +107,9 @@ fun ArtistsContent(
) )
}, },
leadingContent = { leadingContent = {
CoverArt( ArtistIcon(
ref = coverArt(artist.coverArt, true), artist = artist,
name = artist.name, art = art,
modifier = Modifier.size(48.dp).clip(CircleShape), modifier = Modifier.size(48.dp).clip(CircleShape),
) )
}, },
@@ -117,7 +120,7 @@ fun ArtistsContent(
Modifier.combinedClickable( Modifier.combinedClickable(
onClick = { onArtistClick(artist) }, onClick = { onArtistClick(artist) },
onLongClick = { 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), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
@@ -6,6 +6,7 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -43,6 +45,7 @@ import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
@@ -71,7 +74,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
@@ -94,12 +96,14 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.PagingData import androidx.paging.PagingData
@@ -120,6 +124,7 @@ import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.StarredFilter import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest import com.subtracks.ui.components.DismissOnRequest
@@ -308,6 +313,7 @@ fun LibraryRoute(
artists = viewModel.artists, artists = viewModel.artists,
playlists = viewModel.playlists, playlists = viewModel.playlists,
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onPlaylistClick = onPlaylistClick, onPlaylistClick = onPlaylistClick,
@@ -396,6 +402,7 @@ fun LibraryScreen(
onSync: () -> Unit, onSync: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
onOpenSearch: () -> Unit = {}, onOpenSearch: () -> Unit = {},
offline: Boolean = false, offline: Boolean = false,
onExitOffline: () -> Unit = {}, onExitOffline: () -> Unit = {},
@@ -539,6 +546,7 @@ fun LibraryScreen(
onSync = onSync, onSync = onSync,
downloadStatuses = artistDownloads, downloadStatuses = artistDownloads,
scrollToTopKey = scrollToTopKey, scrollToTopKey = scrollToTopKey,
artistArt = artistArt,
) )
} }
@@ -588,8 +596,6 @@ fun LibraryScreen(
if (tab == selectedTab) topRequest++ else onTabSelected(tab) if (tab == selectedTab) topRequest++ else onTabSelected(tab)
}, },
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
offline = offline,
onExitOffline = onExitOffline,
artwork = artwork, artwork = artwork,
syncing = syncing, syncing = syncing,
) )
@@ -611,11 +617,29 @@ fun LibraryScreen(
} }
if (!searchActive) { 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 val searchFab = selectedTab == LibraryTab.Home
FloatingActionButton( FloatingActionButton(
onClick = { if (searchFab) onOpenSearch() else showOptions = true }, onClick = { if (searchFab) onOpenSearch() else showOptions = true },
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh, containerColor = fabContainerColor,
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground, contentColor = fabContentColor,
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp), elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp), modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
) { ) {
@@ -634,11 +658,18 @@ fun LibraryScreen(
modifier = modifier =
Modifier Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.offset(x = 4.dp, y = (-4).dp) .offset(x = 1.dp, y = 2.5.dp)
.size(8.dp) .size(11.dp)
.clip(CircleShape) .background(fabContainerColor, CircleShape),
.background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary), contentAlignment = Alignment.Center,
) ) {
Box(
modifier =
Modifier
.size(7.dp)
.background(fabContentColor, CircleShape),
)
}
} }
} }
} }
@@ -698,7 +729,7 @@ private fun ListOptionsSheet(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), 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) Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant) Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -820,6 +851,7 @@ private fun ListOptionsSheet(
private val BOTTOM_CLEARANCE = 80.dp private val BOTTOM_CLEARANCE = 80.dp
private val TAB_BAR_CONTENT_HEIGHT = 52.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_ICON_SIZE = 24.dp
private val TAB_VERTICAL_PADDING = 6.dp private val TAB_VERTICAL_PADDING = 6.dp
private val TOUCH_TARGET = 48.dp private val TOUCH_TARGET = 48.dp
@@ -852,13 +884,12 @@ internal fun LibraryTabs(
pagerState: PagerState, pagerState: PagerState,
onTabSelected: (LibraryTab) -> Unit, onTabSelected: (LibraryTab) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
offline: Boolean = false,
onExitOffline: () -> Unit = {},
artwork: ArtworkColors?, artwork: ArtworkColors?,
syncing: Boolean = false, syncing: Boolean = false,
) { ) {
val indicatorStretch = 18.dp val indicatorStretch = 18.dp
val density = LocalDensity.current val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val bounds = remember { mutableStateMapOf<Int, Rect>() } val bounds = remember { mutableStateMapOf<Int, Rect>() }
val page = pagerState.currentPage val page = pagerState.currentPage
val fraction = pagerState.currentPageOffsetFraction val fraction = pagerState.currentPageOffsetFraction
@@ -890,79 +921,113 @@ internal fun LibraryTabs(
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() } 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.fillMaxWidth()) {
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(headerColor) .background(headerColor)
.padding(horizontal = 8.dp, vertical = 2.dp), .padding(top = 2.dp, end = 8.dp, bottom = 2.dp),
) { ) {
Box( Row(
modifier = modifier = Modifier.fillMaxWidth(),
Modifier.drawBehind { verticalAlignment = Alignment.CenterVertically,
if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) { horizontalArrangement = Arrangement.spacedBy(4.dp),
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( Row(
modifier = Modifier.fillMaxWidth(), modifier =
Modifier
.weight(1f)
.onSizeChanged { viewportWidth = it.width }
.horizontalScroll(scrollState)
.onSizeChanged { contentWidth = it.width },
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
LibraryTab.entries.forEachIndexed { index, tab -> Spacer(Modifier.width(TAB_STRIP_LEADING_GAP))
TabButton( Box(
tab = tab, modifier =
artwork = artwork, Modifier
progress = .drawBehind {
when (index) { if (current != null &&
page -> 1f - transition indicatorLeft != null &&
page + (if (fraction >= 0f) 1 else -1) -> transition indicatorRight != null &&
else -> 0f 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, Row(
onClick = { onTabSelected(tab) }, verticalAlignment = Alignment.CenterVertically,
modifier = horizontalArrangement = Arrangement.spacedBy(4.dp),
Modifier.onGloballyPositioned { coordinates -> ) {
bounds[index] = coordinates.boundsInParent() LibraryTab.entries.forEachIndexed { index, tab ->
}, TabButton(
) tab = tab,
} artwork = artwork,
Spacer(Modifier.weight(1f)) progress =
if (offline) { when (index) {
IconButton(onClick = onExitOffline) { page -> 1f - transition
Icon( page + (if (fraction >= 0f) 1 else -1) -> transition
imageVector = Icons.Rounded.CloudOff, else -> 0f
contentDescription = stringResource(R.string.offline_mode_status), },
tint = MaterialTheme.colorScheme.onBackground, indicatorLeft = indicatorLeft,
) indicatorRight = indicatorRight,
} onClick = { onTabSelected(tab) },
} modifier =
IconButton(onClick = onOpenSettings) { Modifier.onGloballyPositioned { coordinates ->
Box(contentAlignment = Alignment.Center) { bounds[index] = coordinates.boundsInParent()
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,
) )
} }
} }
} }
} }
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.SyncManager
import com.subtracks.data.sync.SyncStatus import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -211,6 +213,11 @@ class LibraryViewModel(
thumbnail: Boolean, thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun sync() { fun sync() {
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync() if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
} }
@@ -55,7 +55,7 @@ fun PlaylistDetailRoute(
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) }, viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
playbackController: PlaybackController = koinInject(), playbackController: PlaybackController = koinInject(),
) { ) {
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null) val playlist by viewModel.playlist.collectAsStateWithLifecycle()
val downloads by viewModel.downloads.collectAsStateWithLifecycle() val downloads by viewModel.downloads.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle()
@@ -138,6 +138,7 @@ fun PlaylistDetailScreen(
artwork = artwork, artwork = artwork,
title = playlist?.name.orEmpty(), title = playlist?.name.orEmpty(),
onBack = onBack, onBack = onBack,
contentReady = songs.itemCount > 0,
header = { controlsModifier, topInset -> header = { controlsModifier, topInset ->
HeroHeader( HeroHeader(
art = coverArt(playlist?.coverArt, false), art = coverArt(playlist?.coverArt, false),
@@ -37,7 +37,10 @@ class PlaylistDetailViewModel(
) : ViewModel() { ) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull() 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>> = val songs: Flow<PagingData<PlaylistSongItem>> =
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline } combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
@@ -44,6 +44,7 @@ import com.subtracks.R
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.playback.PlaybackState import com.subtracks.playback.PlaybackState
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.artistSubtitle
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.playerSurfaceColor import com.subtracks.ui.theme.playerSurfaceColor
@@ -139,7 +140,7 @@ fun MiniPlayer(
modifier = Modifier.basicMarquee(), modifier = Modifier.basicMarquee(),
) )
Text( Text(
text = item.artist ?: item.album.orEmpty(), text = artistSubtitle(item.artist, item.artists) ?: item.album.orEmpty(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
@@ -90,6 +90,7 @@ import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.artistSubtitle
import com.subtracks.ui.components.prefetchArtworkRatio import com.subtracks.ui.components.prefetchArtworkRatio
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkSeedCache import com.subtracks.ui.theme.ArtworkSeedCache
@@ -576,7 +577,10 @@ fun NowPlayingScreen(
} }
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) { Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
Text( Text(
text = state.item?.artist.orEmpty(), text =
state.item
?.let { artistSubtitle(it.artist, it.artists) }
.orEmpty(),
style = subtitleStyle, style = subtitleStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
@@ -46,6 +46,7 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
@@ -106,6 +107,7 @@ fun SearchRoute(
if (next != 0) filterMask = next if (next != 0) filterMask = next
}, },
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
artistArt = { viewModel.artistArt(it) },
playingSongId = playback.item?.id, playingSongId = playback.item?.id,
onBack = onBack, onBack = onBack,
onAlbumClick = { onAlbumClick = {
@@ -146,6 +148,7 @@ fun SearchScreen(
filters: Set<SearchFilter> = ALL_FILTERS, filters: Set<SearchFilter> = ALL_FILTERS,
onToggleFilter: (SearchFilter) -> Unit = {}, onToggleFilter: (SearchFilter) -> Unit = {},
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null }, coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
playingSongId: String? = null, playingSongId: String? = null,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onAlbumClick: (Album) -> Unit = {}, onAlbumClick: (Album) -> Unit = {},
@@ -215,7 +218,7 @@ fun SearchScreen(
contentPadding = PaddingValues(bottom = 24.dp), contentPadding = PaddingValues(bottom = 24.dp),
) { ) {
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick) 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 (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick) if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
} }
@@ -308,19 +311,20 @@ private fun LazyListScope.albumResults(
private fun LazyListScope.artistResults( private fun LazyListScope.artistResults(
artists: List<Artist>, artists: List<Artist>,
header: String, header: String,
coverArt: (String?, Boolean) -> CoverArtRef?, artistArt: (Artist) -> ArtistArt,
onClick: (Artist) -> Unit, onClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit, onLongClick: (MenuTarget) -> Unit,
) { ) {
if (artists.isEmpty()) return if (artists.isEmpty()) return
resultHeader(header) resultHeader(header)
items(artists, key = { "artist-${it.id}" }) { artist -> items(artists, key = { "artist-${it.id}" }) { artist ->
val art = artistArt(artist)
ArtistListRow( ArtistListRow(
artist = artist, artist = artist,
coverArt = coverArt, art = art,
info = null, info = null,
onClick = { onClick(artist) }, 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.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.artistArt
import com.subtracks.ui.home.playSongInContext import com.subtracks.ui.home.playSongInContext
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -74,6 +76,11 @@ class SearchViewModel(
thumbnail: Boolean, thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun artistArt(
artist: Artist,
forIcon: Boolean = true,
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
fun play(song: Song) { fun play(song: Song) {
viewModelScope.playSongInContext(libraryRepository, playbackController, song) viewModelScope.playSongInContext(libraryRepository, playbackController, song)
} }
@@ -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.MAX_REPLAY_GAIN_PREAMP_DB
import com.subtracks.data.prefs.MIN_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.ReplayGainMode
import com.subtracks.data.prefs.SortNameMode
import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.SyncMode import com.subtracks.data.prefs.SyncMode
import com.subtracks.log.Log import com.subtracks.log.Log
@@ -101,6 +102,7 @@ private enum class SettingsDialog {
SyncMode, SyncMode,
ReplayGain, ReplayGain,
ReplayGainPreamp, ReplayGainPreamp,
SortNames,
} }
@Composable @Composable
@@ -128,6 +130,7 @@ fun SettingsRoute(
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle() val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle() val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle() val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
val sortNameMode by viewModel.sortNameMode.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
SettingsScreen( SettingsScreen(
@@ -143,6 +146,7 @@ fun SettingsRoute(
scrobbling = scrobbling, scrobbling = scrobbling,
replayGainMode = replayGainMode, replayGainMode = replayGainMode,
replayGainPreamp = replayGainPreamp, replayGainPreamp = replayGainPreamp,
sortNameMode = sortNameMode,
keepScreenOn = keepScreenOn, keepScreenOn = keepScreenOn,
offline = offline, offline = offline,
verboseLogging = verboseLogging, verboseLogging = verboseLogging,
@@ -158,6 +162,7 @@ fun SettingsRoute(
onScrobblingChange = viewModel::setScrobbling, onScrobblingChange = viewModel::setScrobbling,
onReplayGainModeChange = viewModel::setReplayGainMode, onReplayGainModeChange = viewModel::setReplayGainMode,
onReplayGainPreampChange = viewModel::setReplayGainPreamp, onReplayGainPreampChange = viewModel::setReplayGainPreamp,
onSortNameModeChange = viewModel::setSortNameMode,
onKeepScreenOnChange = viewModel::setKeepScreenOn, onKeepScreenOnChange = viewModel::setKeepScreenOn,
onOfflineChange = viewModel::setOfflineMode, onOfflineChange = viewModel::setOfflineMode,
onVerboseLoggingChange = viewModel::setVerboseLogging, onVerboseLoggingChange = viewModel::setVerboseLogging,
@@ -205,6 +210,7 @@ fun SettingsScreen(
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES, syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
replayGainMode: ReplayGainMode = ReplayGainMode.Auto, replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
replayGainPreamp: Float = 0f, replayGainPreamp: Float = 0f,
sortNameMode: SortNameMode = SortNameMode.Name,
onSelectSource: (Long) -> Unit, onSelectSource: (Long) -> Unit,
onEditServer: (Long) -> Unit, onEditServer: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit, onWifiQualityChange: (StreamQuality) -> Unit,
@@ -217,6 +223,7 @@ fun SettingsScreen(
onScrobblingChange: (Boolean) -> Unit, onScrobblingChange: (Boolean) -> Unit,
onReplayGainModeChange: (ReplayGainMode) -> Unit = {}, onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
onReplayGainPreampChange: (Float) -> Unit = {}, onReplayGainPreampChange: (Float) -> Unit = {},
onSortNameModeChange: (SortNameMode) -> Unit = {},
onKeepScreenOnChange: (Boolean) -> Unit = {}, onKeepScreenOnChange: (Boolean) -> Unit = {},
onOfflineChange: (Boolean) -> Unit = {}, onOfflineChange: (Boolean) -> Unit = {},
onVerboseLoggingChange: (Boolean) -> Unit = {}, onVerboseLoggingChange: (Boolean) -> Unit = {},
@@ -348,6 +355,15 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent), 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 { SectionHeader(stringResource(R.string.settings_music_name)) }
item { item {
ListItem( ListItem(
@@ -603,6 +619,14 @@ fun SettingsScreen(
) )
} }
SettingsDialog.SortNames -> {
SortNameModeDialog(
selected = sortNameMode,
onSelect = onSortNameModeChange,
onDismiss = { dialog = null },
)
}
null -> { null -> {
Unit 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 @Composable
private fun ReplayGainModeDialog( private fun ReplayGainModeDialog(
selected: ReplayGainMode, selected: ReplayGainMode,
@@ -6,6 +6,7 @@ import com.subtracks.data.model.Source
import com.subtracks.data.net.NetworkMode import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
import com.subtracks.data.prefs.ReplayGainMode import com.subtracks.data.prefs.ReplayGainMode
import com.subtracks.data.prefs.SortNameMode
import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.SyncMode import com.subtracks.data.prefs.SyncMode
import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.UserPreferences
@@ -76,6 +77,11 @@ class SettingsViewModel(
.replayGainPreampDb() .replayGainPreampDb()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f) .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> = val keepScreenOn: StateFlow<Boolean> =
userPreferences userPreferences
.keepScreenOn() .keepScreenOn()
@@ -143,4 +149,8 @@ class SettingsViewModel(
fun setKeepScreenOn(enabled: Boolean) { fun setKeepScreenOn(enabled: Boolean) {
viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) } viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) }
} }
fun setSortNameMode(mode: SortNameMode) {
viewModelScope.launch { userPreferences.setSortNameMode(mode) }
}
} }
+24
View File
@@ -79,6 +79,7 @@
<string name="settings_about_verbose_logging">Verbose logging</string> <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_verbose_logging_description">Log extra detail for troubleshooting</string>
<string name="settings_about_version">Version %1$s</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_name">Playback</string>
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</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_music_options_scrobble_title">Scrobble to server</string>
@@ -96,6 +97,11 @@
<string name="settings_replay_gain_title">ReplayGain</string> <string name="settings_replay_gain_title">ReplayGain</string>
<string name="settings_music_options_keep_screen_on_description">Keep the screen on while now playing is open</string> <string name="settings_music_options_keep_screen_on_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_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_name">Network</string>
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</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> <string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
@@ -191,6 +197,24 @@
<string name="info_sample_rate_value">%1$d Hz</string> <string name="info_sample_rate_value">%1$d Hz</string>
<string name="info_channels">Channels</string> <string name="info_channels">Channels</string>
<string name="info_close">Close</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_header">Disc %1$d</string>
<string name="disc_track">%1$s: Disc %2$d</string> <string name="disc_track">%1$s: Disc %2$d</string>
@@ -20,6 +20,7 @@ import com.subtracks.data.model.Source
import com.subtracks.data.prefs.AlbumSort import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SortNameMode
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
@@ -332,10 +333,18 @@ class LibraryDaoTest {
runTest { runTest {
val orders = val orders =
listOf( listOf(
ALBUM_ORDER_BY_NAME to "index_albums_name", "${albumNameSortKey(SortNameMode.SortName)}, id" to "index_albums_name",
ALBUM_ORDER_BY_NAME_REVERSED to "index_albums_name", "${albumNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_albums_name",
ALBUM_ORDER_BY_ARTIST to "index_albums_artist", "${albumNameSortKey(SortNameMode.Name)}, id" to "index_albums_name_plain",
ALBUM_ORDER_BY_ARTIST_REVERSED to "index_albums_artist", "${albumNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_albums_name_plain",
"${albumArtistSortKey(SortNameMode.SortName)}, year, " +
"${albumNameSortKey(SortNameMode.SortName)}, id" to "index_albums_artist",
"${albumArtistSortKey(SortNameMode.SortName)} DESC, year DESC, " +
"${albumNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_albums_artist",
"${albumArtistSortKey(SortNameMode.Name)}, year, " +
"${albumNameSortKey(SortNameMode.Name)}, id" to "index_albums_artist_plain",
"${albumArtistSortKey(SortNameMode.Name)} DESC, year DESC, " +
"${albumNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_albums_artist_plain",
ALBUM_ORDER_BY_YEAR to "index_albums_year", ALBUM_ORDER_BY_YEAR to "index_albums_year",
ALBUM_ORDER_BY_YEAR_REVERSED to "index_albums_year", ALBUM_ORDER_BY_YEAR_REVERSED to "index_albums_year",
ALBUM_ORDER_BY_ADDED to "index_albums_added", ALBUM_ORDER_BY_ADDED to "index_albums_added",
@@ -359,8 +368,10 @@ class LibraryDaoTest {
runTest { runTest {
val orders = val orders =
listOf( listOf(
ARTIST_ORDER_BY_NAME to "index_artists_name", "${artistNameSortKey(SortNameMode.SortName)}, id" to "index_artists_name",
ARTIST_ORDER_BY_NAME_REVERSED to "index_artists_name", "${artistNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_artists_name",
"${artistNameSortKey(SortNameMode.Name)}, id" to "index_artists_name_plain",
"${artistNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_artists_name_plain",
ARTIST_ORDER_BY_ALBUM_COUNT to "index_artists_albumCount", ARTIST_ORDER_BY_ALBUM_COUNT to "index_artists_albumCount",
ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED to "index_artists_albumCount", ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED to "index_artists_albumCount",
ARTIST_ORDER_BY_STARRED to "index_artists_starred", ARTIST_ORDER_BY_STARRED to "index_artists_starred",
@@ -548,7 +559,7 @@ class LibraryDaoTest {
} }
@Test @Test
fun artistArtFallsBackToTheFirstAlbumCreditThenTheFirstTrackCredit() = fun artistArtIsMaterializedFromAlbumCreditsThenTrackCredits() =
runTest { runTest {
val sourceId = source() val sourceId = source()
val dao = db.libraryDao() val dao = db.libraryDao()
@@ -615,40 +626,241 @@ class LibraryDaoTest {
} }
} }
dao.recomputeArtistArt(sourceId)
val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id } val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id }
assertEquals("own-art", artists.getValue("ar-own").coverArt) assertEquals("own-art", artists.getValue("ar-own").coverArt)
assertEquals("new-art", artists.getValue("ar-album").coverArt) assertNull(artists.getValue("ar-album").coverArt)
assertEquals("track-art", artists.getValue("ar-track").coverArt) assertNull(artists.getValue("ar-track").coverArt)
assertEquals("album-ref-art", artists.getValue("ar-album-ref").coverArt) assertNull(artists.getValue("ar-album-ref").coverArt)
assertEquals("track-ref-art", artists.getValue("ar-track-ref").coverArt) assertNull(artists.getValue("ar-track-ref").coverArt)
assertNull(artists.getValue("ar-null-art").coverArt) assertNull(artists.getValue("ar-null-art").coverArt)
assertNull(artists.getValue("ar-none").coverArt) assertNull(artists.getValue("ar-none").coverArt)
assertEquals("new-art", dao.artistOnce(sourceId, "ar-album")!!.coverArt) assertEquals(listOf("own-album"), artists.getValue("ar-own").coverArts)
assertEquals(listOf("new-art", "old-art"), artists.getValue("ar-album").coverArts)
assertEquals(listOf("track-art"), artists.getValue("ar-track").coverArts)
assertEquals(listOf("album-ref-art"), artists.getValue("ar-album-ref").coverArts)
assertEquals(listOf("track-ref-art"), artists.getValue("ar-track-ref").coverArts)
assertEquals(listOf("later-art"), artists.getValue("ar-null-art").coverArts)
assertEquals(emptyList<String>(), artists.getValue("ar-none").coverArts)
assertEquals(listOf("new-art", "old-art"), dao.artistOnce(sourceId, "ar-album")!!.coverArts)
assertEquals( assertEquals(
"album-ref-art", listOf("album-ref-art"),
dao dao
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false)) .artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
.page() .page()
.first { it.id == "ar-album-ref" } .first { it.id == "ar-album-ref" }
.coverArt, .coverArts,
) )
} }
@Test @Test
fun artistArtFallbackFindsCreditsThroughArtistIndexes() = fun coverArtFallbackFindsEveryCreditPath() =
runTest { runTest {
val plan = plan("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME LIMIT 20") val sourceId = source()
val dao = db.libraryDao()
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)")) dao.upsertAlbums(
assertTrue( listOf(
plan, album(sourceId, "al-1", "Album", year = 2020, starred = null)
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"), .copy(artistId = "ar-other", coverArt = "track-art"),
album(sourceId, "al-2", "Featured", year = 2021, starred = null)
.copy(artistId = "ar-other", coverArt = "featured-art"),
),
) )
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)")) dao.upsertSongs(
assertTrue( listOf(
plan, song(sourceId, "s-1", "Song", starred = null)
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"), .copy(albumId = "al-1", artistId = "ar-missing"),
song(sourceId, "s-2", "Featured", starred = null)
.copy(albumId = "al-2", artistId = "ar-other"),
),
)
db.useWriterConnection { connection ->
connection.usePrepared(
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
"VALUES (?, 'al-2', 0, 'ar-featured', 'Featured')",
) { statement ->
statement.bindLong(1, sourceId)
statement.step()
}
connection.usePrepared(
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
"VALUES (?, 's-2', 0, 'ar-guest', 'Guest')",
) { statement ->
statement.bindLong(1, sourceId)
statement.step()
}
}
assertNull(dao.artistOnce(sourceId, "ar-missing"))
assertNull(dao.artistOnce(sourceId, "ar-featured"))
assertNull(dao.artistOnce(sourceId, "ar-guest"))
assertEquals(listOf("track-art"), dao.artistCoverArts(sourceId, "ar-missing").first())
assertEquals(listOf("featured-art", "track-art"), dao.artistCoverArts(sourceId, "ar-other").first())
assertEquals(listOf("featured-art"), dao.artistCoverArts(sourceId, "ar-featured").first())
assertEquals(listOf("featured-art"), dao.artistCoverArts(sourceId, "ar-guest").first())
}
@Test
fun artistCoverArtsRanksDeduplicatesCapsAndStaysInSource() =
runTest {
val sourceId = source()
val otherSourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(listOf(artist(sourceId, "ar-rank", "Rank", albumCount = 0)))
dao.upsertAlbums(
listOf(
album(sourceId, "a-new", "New", year = 2020, starred = null)
.copy(artistId = "ar-rank", coverArt = "shared"),
album(sourceId, "a-mid", "Mid", year = 2015, starred = null)
.copy(artistId = "ar-rank", coverArt = "other"),
album(sourceId, "a-old", "Old", year = 2010, starred = null)
.copy(artistId = "ar-rank", coverArt = "shared"),
album(sourceId, "a-4", "Four", year = 2012, starred = null)
.copy(artistId = "ar-rank", coverArt = "c4"),
album(sourceId, "a-5", "Five", year = 2011, starred = null)
.copy(artistId = "ar-rank", coverArt = "c5"),
album(sourceId, "a-6", "Six", year = 2009, starred = null)
.copy(artistId = "ar-rank", coverArt = "c6"),
album(otherSourceId, "a-leak", "Leak", year = 2025, starred = null)
.copy(artistId = "ar-rank", coverArt = "leak"),
),
)
val expected = listOf("shared", "other", "c4", "c5")
assertEquals(expected, dao.artistCoverArts(sourceId, "ar-rank").first())
dao.recomputeArtistArt(sourceId)
assertEquals(expected, dao.artistOnce(sourceId, "ar-rank")!!.coverArts)
}
@Test
fun downloadedArtistArtUsesOnlyDownloadedAlbums() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 3)))
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "One", year = 2020, starred = null)
.copy(artistId = "ar-1", coverArt = "art-1"),
album(sourceId, "al-2", "Two", year = 2021, starred = null)
.copy(artistId = "ar-1", coverArt = "art-2"),
album(sourceId, "al-3", "Three", year = 2022, starred = null)
.copy(artistId = "ar-1", coverArt = "art-3"),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1", artistId = "ar-1"),
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", artistId = "ar-1"),
song(sourceId, "s3", "Three", starred = null).copy(albumId = "al-3", artistId = "ar-1"),
),
)
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Queued))
dao.recomputeArtistArt(sourceId)
val artist = dao.artistOnce(sourceId, "ar-1")!!
assertEquals(listOf("art-3", "art-2", "art-1"), artist.coverArts)
assertEquals(listOf("art-1"), artist.downloadedCoverArts)
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed))
dao.recomputeDownloadedArtistArt(sourceId, listOf("ar-1"))
assertEquals(listOf("art-2", "art-1"), dao.artistOnce(sourceId, "ar-1")!!.downloadedCoverArts)
}
@Test
fun artistNameUpdatesKeepTheSearchIndexValid() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 0)))
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Renamed", albumCount = 0)))
val hits =
db.useReaderConnection { connection ->
connection.usePrepared("SELECT COUNT(*) FROM artist_search WHERE artist_search MATCH 'Renamed'") {
it.step()
it.getLong(0)
}
}
assertEquals(1L, hits)
}
@Test
fun artistArtDeduplicatesCoversBeforeApplyingTheFourCoverBudget() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 6)))
dao.upsertAlbums(
listOf("A", "A", "O", "B", "C", "D").mapIndexed { index, cover ->
album(sourceId, "al-$index", "Album $index", year = 2021L - index, starred = null)
.copy(artistId = "ar-1", coverArt = cover)
},
)
dao.recomputeArtistArt(sourceId)
assertEquals(
listOf("A", "O", "B", "C"),
dao.artistOnce(sourceId, "ar-1")!!.coverArts,
)
}
@Test
fun artistArtMaterializationUsesTheCreditIndexes() =
runTest {
val plan = plan(ARTIST_ART_UPDATE_SQL)
assertTrue(plan, plan.contains("index_albums_sourceId_artistId"))
assertTrue(plan, plan.contains("index_album_artists_sourceId_artistId"))
assertTrue(plan, plan.contains("index_songs_sourceId_artistId"))
assertTrue(plan, plan.contains("index_song_artists_sourceId_artistId"))
}
@Test
fun creditedSongArtistsIncludeTheAlbumArtistAndTrackCredits() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-album", "Album Artist", albumCount = 1),
artist(sourceId, "ar-track", "Track Artist", albumCount = 0),
artist(sourceId, "ar-guest", "Guest", albumCount = 0),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "Album", year = null, starred = null)
.copy(artistId = "ar-album"),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s1", "Song", starred = null)
.copy(albumId = "al-1", artistId = "ar-track"),
),
)
db.useWriterConnection { connection ->
connection.usePrepared(
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
"VALUES (?, 's1', 0, 'ar-guest', 'Guest')",
) { statement ->
statement.bindLong(1, sourceId)
statement.step()
}
}
assertEquals(
setOf("ar-album", "ar-track", "ar-guest"),
dao.creditedArtistIdsForSongs(sourceId, listOf("s1")).toSet(),
) )
} }
@@ -804,7 +1016,12 @@ class LibraryDaoTest {
runTest { runTest {
val sourceId = source() val sourceId = source()
val dao = db.libraryDao() val dao = db.libraryDao()
dao.upsertPlaylists(listOf(playlist(sourceId, "pl-1", "List", created = 0, changed = 0).copy(songCount = 5))) dao.upsertPlaylists(
listOf(
playlist(sourceId, "pl-1", "List", created = 0, changed = 0)
.copy(songCount = 5, owner = "austin", shared = true),
),
)
dao.upsertSongs( dao.upsertSongs(
listOf( listOf(
song(sourceId, "s1", "One", starred = null), song(sourceId, "s1", "One", starred = null),
@@ -822,6 +1039,8 @@ class LibraryDaoTest {
val filtered = dao.playlistsByName(sourceId, "", downloadedFilter = 1).page() val filtered = dao.playlistsByName(sourceId, "", downloadedFilter = 1).page()
assertEquals(1, filtered.size) assertEquals(1, filtered.size)
assertEquals(1L, filtered.first().songCount) assertEquals(1L, filtered.first().songCount)
assertEquals("austin", filtered.first().owner)
assertEquals(true, filtered.first().shared)
assertEquals( assertEquals(
5L, 5L,
@@ -935,7 +1154,7 @@ class LibraryDaoTest {
val plan = val plan =
plan( plan(
"SELECT * ${ALBUMS_FILTER.replace(":search", "'abc'")} " + "SELECT * ${ALBUMS_FILTER.replace(":search", "'abc'")} " +
"ORDER BY $ALBUM_ORDER_BY_NAME LIMIT 20 OFFSET 0", "ORDER BY ${albumNameSortKey(SortNameMode.SortName)}, id LIMIT 20 OFFSET 0",
) )
assertTrue(plan, plan.contains("album_search")) assertTrue(plan, plan.contains("album_search"))
@@ -1200,6 +1419,70 @@ class LibraryDaoTest {
) )
} }
@Test
fun theSortNamePreferenceChoosesTheOrderKey() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-1", "あいみょん", albumCount = 1).copy(sortName = "Zzz"),
artist(sourceId, "ar-2", "宇多田ヒカル", albumCount = 1).copy(sortName = "Aaa"),
artist(sourceId, "ar-3", "Beatles", albumCount = 1).copy(sortName = "Beatles, The"),
artist(sourceId, "ar-4", "Zebra", albumCount = 1).copy(sortName = "Aaa"),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "Zzz", year = null, starred = null)
.copy(sortName = "Aaa", albumArtistSort = "Zzz", albumArtist = "Beatles"),
album(sourceId, "al-2", "Aaa", year = null, starred = null)
.copy(sortName = "Zzz", albumArtistSort = "Aaa", albumArtist = "Aaa"),
album(sourceId, "al-3", "Beatles", year = null, starred = null)
.copy(sortName = "Beatles, The", albumArtistSort = "Beatles, The", albumArtist = "Zebra"),
),
)
assertEquals(
listOf("宇多田ヒカル", "Zebra", "Beatles", "あいみょん"),
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.SortName.name).page().map { it.name },
)
assertEquals(
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
)
assertEquals(
listOf("宇多田ヒカル", "あいみょん", "Zebra", "Beatles"),
dao.artistsByNameReversed(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
)
assertEquals(
listOf("宇多田ヒカル", "Beatles", "Zebra", "あいみょん"),
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.NonLatin.name).page().map { it.name },
)
assertEquals(
listOf("Aaa", "Beatles", "Zzz"),
dao.albumsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
)
assertEquals(
listOf("Aaa", "Zzz", "Beatles"),
dao.albumsByArtist(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
)
assertEquals(
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
dao
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false, SortNameMode.Name))
.page()
.map { it.name },
)
assertEquals(
listOf("Aaa", "Beatles", "Zzz"),
dao
.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false, SortNameMode.Name))
.page()
.map { it.name },
)
}
@Test @Test
fun sortNamesAreSearchableInArtistsAlbumsAndSongs() = fun sortNamesAreSearchableInArtistsAlbumsAndSongs() =
runTest { runTest {
@@ -7,6 +7,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.json.JSONObject import org.json.JSONObject
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.io.File import java.io.File
@@ -153,6 +154,111 @@ class MigrationsTest {
} }
} }
@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 @Test
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() = fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
runTest { runTest {
@@ -395,6 +501,16 @@ class MigrationsTest {
statement.getLong(0) 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( private fun indexColumn(
connection: SQLiteConnection, connection: SQLiteConnection,
index: String, index: String,
@@ -428,6 +544,10 @@ class MigrationsTest {
for (i in 0 until indices.length()) { for (i in 0 until indices.length()) {
connection.execSQL(indices.getJSONObject(i).getString("createSql").replace("\${TABLE_NAME}", name)) 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))
}
} }
} }
@@ -39,7 +39,12 @@ class QueryTimingMeasurementTest {
fun setUp() { fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>() val context = ApplicationProvider.getApplicationContext<Context>()
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it } val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
db = Room.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java).setDriver(driver).build() db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(driver)
.addCallback(ArtistSearchTriggerOptimization)
.build()
} }
@After @After
@@ -51,6 +51,22 @@ class UserPreferencesTest {
file.delete() 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 @Test
fun theSelectedLibraryTabRoundTrips() = fun theSelectedLibraryTabRoundTrips() =
runTest { runTest {
@@ -26,6 +26,7 @@ import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -70,11 +71,12 @@ class DownloadRepositoryTest {
private val requestedArt = CopyOnWriteArrayList<String>() private val requestedArt = CopyOnWriteArrayList<String>()
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>() private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
private var failArtworkFetch = false private var failArtworkFetch = false
private val failingArtUrls = CopyOnWriteArrayList<String>()
private val fetcher = private val fetcher =
ArtworkFetcher { url -> ArtworkFetcher { url ->
requestedArt += url requestedArt += url
artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty() artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty()
if (failArtworkFetch) throw IOException("boom") if (failArtworkFetch || url in failingArtUrls) throw IOException("boom")
byteArrayOf(1, 2, 3) byteArrayOf(1, 2, 3)
} }
@@ -344,7 +346,10 @@ class DownloadRepositoryTest {
runTest { runTest {
seedLibrary() seedLibrary()
writeFile(1, "s1", "audio") 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 val id = engine.requests.single().first
runBlocking { repository.removeSource(1) } runBlocking { repository.removeSource(1) }
@@ -352,6 +357,13 @@ class DownloadRepositoryTest {
assertTrue(engine.cancelled.contains(id)) assertTrue(engine.cancelled.contains(id))
assertFalse(file(1, "s1").exists()) assertFalse(file(1, "s1").exists())
assertTrue(allRows().isEmpty()) assertTrue(allRows().isEmpty())
assertTrue(
db
.libraryDao()
.artistOnce(1, "ar1")!!
.downloadedCoverArts
.isEmpty(),
)
} }
@Test @Test
@@ -381,6 +393,95 @@ class DownloadRepositoryTest {
} }
} }
@Test
fun startingADownloadStoresTheProviderArtistImageAndKeepsItUntilTheDownloadGoes() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg")))
}
runBlocking { repository.download(1, "s1") }
assertTrue(requestedArt.contains("https://img.example/ar1.jpg"))
val key = artistImageKey(1, "ar1")
assertNotNull(artwork.uri(1, key))
runBlocking { repository.reconcile() }
assertNotNull(artwork.uri(1, key))
runBlocking { repository.remove(1, "s1") }
assertNull(artwork.uri(1, key))
}
@Test
fun providerArtworkForTheAlbumArtistSurvivesASweepAndGoesWithTheDownload() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertArtists(
listOf(
artist(ARTIST_ART, "https://img.example/track.jpg").copy(id = "ar-track"),
artist(ARTIST_ART, "https://img.example/album.jpg").copy(id = "ar-album"),
),
)
db.libraryDao().upsertAlbums(listOf(album(ALBUM_ART).copy(artistId = "ar-album")))
db.libraryDao().upsertSongs(listOf(db.libraryDao().songOnce(1, "s1")!!.copy(artistId = "ar-track")))
}
runBlocking { repository.download(1, "s1") }
val trackKey = artistImageKey(1, "ar-track")
val albumKey = artistImageKey(1, "ar-album")
assertNotNull(artwork.uri(1, trackKey))
assertNotNull(artwork.uri(1, albumKey))
runBlocking { repository.reconcile() }
assertNotNull(artwork.uri(1, trackKey))
assertNotNull(artwork.uri(1, albumKey))
runBlocking { repository.remove(1, "s1") }
assertNull(artwork.uri(1, trackKey))
assertNull(artwork.uri(1, albumKey))
}
@Test
fun aFailedProviderImageFetchCachesTheArtistCoverFallback() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg")))
}
failingArtUrls += "https://img.example/ar1.jpg"
runBlocking { repository.download(1, "s1") }
assertNull(artwork.uri(1, artistImageKey(1, "ar1")))
assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, false)))
assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, true)))
}
@Test
fun completingADownloadMaterializesOnlyItsAlbumForOfflineArtistArt() =
runTest {
seedLibrary()
repository.start()
runBlocking { repository.download(1, "s1") }
val id = engine.requests.single().first
engine.complete(id, total = 100)
writeFile(1, "s1", "audio")
runBlocking { repository.reconcile() }
await { row(1, "s1")?.status == DownloadStatus.Completed }
assertEquals(
listOf(ALBUM_ART),
db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts,
)
runBlocking { repository.remove(1, "s1") }
assertEquals(emptyList<String>(), db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts)
}
@Test @Test
fun downloadingASongAlsoStoresTheArtworkOfPlaylistsThatContainIt() = fun downloadingASongAlsoStoresTheArtworkOfPlaylistsThatContainIt() =
runTest { runTest {
@@ -1282,8 +1383,19 @@ class DownloadRepositoryTest {
songCount = 2, songCount = 2,
) )
private fun artist(coverArt: String?) = private fun artist(
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt, songCount = 2) 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(address: String = "http://localhost:4533/") { private fun seedLibrary(address: String = "http://localhost:4533/") {
runBlocking { runBlocking {
@@ -76,6 +76,7 @@ class LibraryRepositoryTest {
db, db,
sourceRepository, sourceRepository,
NetworkServerActionSink(sourceRepository), NetworkServerActionSink(sourceRepository),
fakeUserPreferences(),
showMessage = { messages += it.resolve(resources) }, showMessage = { messages += it.resolve(resources) },
scope = scope, scope = scope,
) )
@@ -11,6 +11,7 @@ import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.artistImageKey
import com.subtracks.data.model.coverArtKey import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.UserPreferences
@@ -33,6 +34,7 @@ import okhttp3.mockwebserver.RecordedRequest
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -283,6 +285,35 @@ class SourceRepositoryTest {
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:")) assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:"))
} }
@Test
fun activeSourceBecomesReadyAfterThePreferencesLoad() =
runBlocking {
repository.addSource("nav", "http://a.example/", "u", "p", false)
val active = withTimeout(TEST_TIMEOUT_MS) { repository.activeSource.first { it != null } }
assertEquals(repository.activeSourceId().first(), active)
}
@Test
fun artistImageUsesTheNetworkOnlineAndOnlyCachedFilesOffline() =
runBlocking {
repository.addSource("nav", "http://a.example/", "u", "p", false)
val sourceId = awaitActiveSourceId()
val url = "https://img.example/ar1.jpg"
val online = withTimeout(TEST_TIMEOUT_MS) { repository.artistImage("ar1", url) }
assertEquals(url, online?.url)
val key = artistImageKey(sourceId, "ar1")
artwork.write(sourceId, key, byteArrayOf(1, 2, 3))
assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url)
repository.setOfflineMode(true)
withTimeout(TEST_TIMEOUT_MS) { repository.offline.first { it } }
assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url)
assertNull(repository.artistImage("ar2", "https://img.example/ar2.jpg"))
}
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! } private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! }
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef = private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
@@ -74,7 +74,8 @@ class SubsonicXmlTest {
""" """
<subsonic-response status="ok"> <subsonic-response status="ok">
<artists> <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"/> <artist id="ar2" name="Portishead" albumCount="3"/>
</artists> </artists>
</subsonic-response> </subsonic-response>
@@ -88,6 +89,8 @@ class SubsonicXmlTest {
assertEquals(7L, artists[0].sourceId) assertEquals(7L, artists[0].sourceId)
assertEquals(9L, artists[0].albumCount) assertEquals(9L, artists[0].albumCount)
assertEquals(1577934245L, artists[0].starred) assertEquals(1577934245L, artists[0].starred)
assertEquals("https://img.example/radiohead.jpg", artists[0].artistImageUrl)
assertEquals(null, artists[1].artistImageUrl)
assertEquals(null, artists[1].starred) assertEquals(null, artists[1].starred)
} }
@@ -742,6 +745,29 @@ class SubsonicXmlTest {
assertEquals(true, failure.exceptionOrNull() is SubsonicException) 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( private class CountingInputStream(
input: InputStream, input: InputStream,
) : FilterInputStream(input) { ) : FilterInputStream(input) {
@@ -258,6 +258,19 @@ class SyncServiceTest {
assertEquals(5678L, stored?.starred) 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 @Test
fun syncStoresSongGenresInOrder() = fun syncStoresSongGenresInOrder() =
runTest { runTest {
@@ -2,6 +2,7 @@ package com.subtracks.playback
import androidx.media3.common.C import androidx.media3.common.C
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.ArtistRef
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -70,6 +71,21 @@ class MediaItemTest {
assertEquals(gain, item.replayGain()) 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 @Test
fun theItemCarriesItsGainContext() { fun theItemCarriesItsGainContext() {
val item = val item =
@@ -1,6 +1,7 @@
package com.subtracks.playback package com.subtracks.playback
import com.subtracks.data.model.AlbumSongItem import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
@@ -57,6 +58,29 @@ class QueueItemTest {
assertEquals(true, item.albumContext) 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 @Test
fun aSongWithoutGainCarriesNoGain() { fun aSongWithoutGainCarriesNoGain() {
val item = val item =
@@ -48,6 +48,7 @@ import com.subtracks.data.prefs.StreamQuality
import com.subtracks.playback.PlaybackState import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem import com.subtracks.playback.QueueItem
import com.subtracks.playback.RepeatMode import com.subtracks.playback.RepeatMode
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.DOWNLOADING_INDICATOR_TAG import com.subtracks.ui.components.DOWNLOADING_INDICATOR_TAG
import com.subtracks.ui.library.ALBUM_COVER_TAG import com.subtracks.ui.library.ALBUM_COVER_TAG
import com.subtracks.ui.library.ARTIST_NAME_SCRIM_TAG import com.subtracks.ui.library.ARTIST_NAME_SCRIM_TAG
@@ -321,6 +322,38 @@ class AppScreenshotTest {
composeRule.onRoot().captureRoboImage("src/test/screenshots/artist_detail_bio_expanded.png") composeRule.onRoot().captureRoboImage("src/test/screenshots/artist_detail_bio_expanded.png")
} }
@Test
fun artistDetailArtGrid() {
composeRule.setContent {
SubtracksTheme {
ArtistDetailScreen(
artist = Fixtures.artists.first(),
albums =
ArtistAlbums(
albumCredits = Fixtures.albums.filter { it.artistId == Fixtures.artists.first().id },
),
art = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
artThumbnail = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"),
heroArt =
ArtistArt(
covers =
Fixtures.albums
.take(3)
.mapNotNull { it.coverArt }
.map { CoverArtRef(it, "test:$it") },
),
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onBack = {},
onAlbumClick = {},
modifier = Modifier.fillMaxSize(),
)
}
}
awaitText("Kid A")
composeRule.onRoot().captureRoboImage("src/test/screenshots/artist_detail_art_grid.png")
}
private fun renderArtistDetail(bioExpanded: Boolean) { private fun renderArtistDetail(bioExpanded: Boolean) {
composeRule.setContent { composeRule.setContent {
SubtracksTheme { SubtracksTheme {
@@ -584,6 +617,16 @@ class AppScreenshotTest {
artists = remember { flowOf(PagingData.from(Fixtures.artists)) }, artists = remember { flowOf(PagingData.from(Fixtures.artists)) },
playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) }, playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) },
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
artistArt = { artist ->
ArtistArt(
covers =
Fixtures.albums
.filter { it.artistId == artist.id }
.take(4)
.mapNotNull { it.coverArt }
.map { CoverArtRef(it, "test:$it") },
)
},
onAlbumClick = {}, onAlbumClick = {},
onArtistClick = {}, onArtistClick = {},
onPlaylistClick = {}, onPlaylistClick = {},
@@ -40,12 +40,14 @@ import com.subtracks.data.model.Source
import com.subtracks.data.model.coverArtKey import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.ListQuery import com.subtracks.data.prefs.ListQuery
import com.subtracks.data.repo.SEARCH_RESULT_LIMIT import com.subtracks.data.repo.SEARCH_RESULT_LIMIT
import com.subtracks.data.repo.normalizeBiography
import com.subtracks.data.source.subsonic.SubsonicSource import com.subtracks.data.source.subsonic.SubsonicSource
import com.subtracks.data.source.subsonic.TestServer import com.subtracks.data.source.subsonic.TestServer
import com.subtracks.data.source.subsonic.TestServers import com.subtracks.data.source.subsonic.TestServers
import com.subtracks.data.sync.SyncService import com.subtracks.data.sync.SyncService
import com.subtracks.playback.PlaybackState import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem import com.subtracks.playback.QueueItem
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.home.HomeFeed import com.subtracks.ui.home.HomeFeed
import com.subtracks.ui.home.HomeScreen import com.subtracks.ui.home.HomeScreen
import com.subtracks.ui.library.AlbumDetailScreen import com.subtracks.ui.library.AlbumDetailScreen
@@ -82,11 +84,15 @@ import javax.imageio.ImageIO
* by `:*UnitTest` or the integration suite; run it explicitly with `gradle :app:demoScreenshots`. * by `:*UnitTest` or the integration suite; run it explicitly with `gradle :app:demoScreenshots`.
* The synced database is rebuilt each run; downloaded cover art is cached under the system temp dir * The synced database is rebuilt each run; downloaded cover art is cached under the system temp dir
* so reruns do not re-fetch it. * so reruns do not re-fetch it.
*
* The device is a 393x786 dp phone at 440 dpi, which renders 1080x2161. A capture one pixel over
* 1080x2160 breaks the stores' 2:1 limit, so the captures are cropped to 1080x2160 afterwards, with
* the last row taken from the row above it.
*/ */
@OptIn(DelicateCoilApi::class) @OptIn(DelicateCoilApi::class)
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL) @Config(qualifiers = "w393dp-h786dp-normal-long-notround-any-440dpi-keyshidden-nonav")
class DemoScreenshotTest { class DemoScreenshotTest {
@get:Rule @get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>() val composeRule = createAndroidComposeRule<ComponentActivity>()
@@ -123,6 +129,7 @@ class DemoScreenshotTest {
HomeScreen( HomeScreen(
feed = demo.feed, feed = demo.feed,
coverArt = demo.coverArt, coverArt = demo.coverArt,
artistArt = demo::artistArt,
playingSongId = demo.homePlayingSong.id, playingSongId = demo.homePlayingSong.id,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
@@ -178,6 +185,7 @@ class DemoScreenshotTest {
ArtistDetailScreen( ArtistDetailScreen(
artist = demo.artist, artist = demo.artist,
artistName = demo.artist.name, artistName = demo.artist.name,
bio = demo.artistBio,
albums = albums =
ArtistAlbums( ArtistAlbums(
albumCredits = albumCredits =
@@ -216,6 +224,7 @@ class DemoScreenshotTest {
onSync = {}, onSync = {},
onOpenSettings = {}, onOpenSettings = {},
artwork = demo.artwork(demo.nowPlayingAlbum.coverArt), artwork = demo.artwork(demo.nowPlayingAlbum.coverArt),
artistArt = demo::artistArt,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
demo.MiniPlayerOverlay(demo.nowPlayingSong, demo.nowPlayingAlbum, progressInset = true) demo.MiniPlayerOverlay(demo.nowPlayingSong, demo.nowPlayingAlbum, progressInset = true)
@@ -228,6 +237,7 @@ class DemoScreenshotTest {
onQueryChange = {}, onQueryChange = {},
results = demo.search, results = demo.search,
coverArt = demo.coverArt, coverArt = demo.coverArt,
artistArt = demo::artistArt,
playingSongId = demo.nowPlayingSong.id, playingSongId = demo.nowPlayingSong.id,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
@@ -288,7 +298,7 @@ class DemoScreenshotTest {
private fun reencodeOpaqueRgb(file: File) { private fun reencodeOpaqueRgb(file: File) {
val decoded = ImageIO.read(file) val decoded = ImageIO.read(file)
val rgb = BufferedImage(decoded.width, decoded.height, BufferedImage.TYPE_INT_RGB) val rgb = BufferedImage(decoded.width, decoded.width * 2, BufferedImage.TYPE_INT_RGB)
rgb.createGraphics().run { rgb.createGraphics().run {
drawImage(decoded, 0, 0, null) drawImage(decoded, 0, 0, null)
dispose() dispose()
@@ -329,6 +339,7 @@ class DemoScreenshotTest {
val homePlayingAlbum: Album, val homePlayingAlbum: Album,
val homePlayingSong: Song, val homePlayingSong: Song,
val artist: Artist, val artist: Artist,
val artistBio: String?,
val artistAlbums: List<Album>, val artistAlbums: List<Album>,
val search: SearchResults, val search: SearchResults,
val coverArt: (String?, Boolean) -> CoverArtRef?, val coverArt: (String?, Boolean) -> CoverArtRef?,
@@ -371,6 +382,15 @@ class DemoScreenshotTest {
return artworkColorsFromSeeds(primary?.rgb ?: FALLBACK_SEED, null) return artworkColorsFromSeeds(primary?.rgb ?: FALLBACK_SEED, null)
} }
fun artistArt(artist: Artist): ArtistArt {
val thumbnail = artist.coverArt?.let { coverArt(it, true) }
return ArtistArt(
image = thumbnail,
thumbnail = thumbnail,
covers = artist.coverArts.take(4).mapNotNull { coverArt(it, true) },
)
}
companion object { companion object {
private const val FALLBACK_SEED = 0xFF3A7BD5.toInt() private const val FALLBACK_SEED = 0xFF3A7BD5.toInt()
private const val DEMO_BASE = "https://demo.navidrome.org/" private const val DEMO_BASE = "https://demo.navidrome.org/"
@@ -417,9 +437,11 @@ class DemoScreenshotTest {
val homePlayingAlbum = allAlbums.firstOrNull { it.name == "Shaking The Habitual" } ?: allAlbums.last() val homePlayingAlbum = allAlbums.firstOrNull { it.name == "Shaking The Habitual" } ?: allAlbums.last()
val homePlayingSong = songsOf(homePlayingAlbum).first() val homePlayingSong = songsOf(homePlayingAlbum).first()
val artist = val artist =
allArtists.firstOrNull { it.name == "Ugress" } allArtists.firstOrNull { it.name == "Nine Inch Nails" }
?: allArtists.firstOrNull { it.name == "pornophonique" }
?: allArtists.maxByOrNull { it.albumCount } ?: allArtists.maxByOrNull { it.albumCount }
?: error("demo library has no artists") ?: error("demo library has no artists")
val artistBio = normalizeBiography(source.artistBiography(artist.id))
val artistAlbums = dao.albumsForArtist(1, artist.id).first() val artistAlbums = dao.albumsForArtist(1, artist.id).first()
val coverArtIds = val coverArtIds =
@@ -509,6 +531,7 @@ class DemoScreenshotTest {
homePlayingAlbum = homePlayingAlbum, homePlayingAlbum = homePlayingAlbum,
homePlayingSong = homePlayingSong, homePlayingSong = homePlayingSong,
artist = artist, artist = artist,
artistBio = artistBio,
artistAlbums = artistAlbums.filter { it.coverArt != null }, artistAlbums = artistAlbums.filter { it.coverArt != null },
search = search, search = search,
coverArt = coverArt, coverArt = coverArt,
@@ -2,8 +2,10 @@ package com.subtracks.ui
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.test.assertWidthIsEqualTo
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithTag
@@ -16,6 +18,9 @@ import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown import androidx.compose.ui.test.swipeDown
import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.test.swipeRight import androidx.compose.ui.test.swipeRight
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.LoadStates import androidx.paging.LoadStates
import androidx.paging.PagingData import androidx.paging.PagingData
@@ -342,6 +347,204 @@ class LibraryScreenTest {
assertTrue("the tab row should reflow in one direction, but the icons moved $samples", monotonic) assertTrue("the tab row should reflow in one direction, but the icons moved $samples", monotonic)
} }
@Test
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
fun aLongTabKeepsTheGearFullSizeAndScrollsIntoView() {
var labelWidth = 0f
composeRule.setContent {
SubtracksTheme {
labelWidth =
rememberTextMeasurer()
.measure(
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
softWrap = false,
).size.width
.toFloat()
LibraryTabs(
pagerState =
remember {
PagerState(
currentPage = LibraryTab.Playlists.ordinal,
pageCount = { LibraryTab.entries.size },
)
},
onTabSelected = {},
onOpenSettings = {},
artwork = null,
)
}
}
composeRule.waitForIdle()
composeRule
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
.assertWidthIsEqualTo(40.dp)
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
}
@Test
@Config(qualifiers = "ar-rEG-w320dp-h640dp-xhdpi")
fun aLongTabScrollsIntoViewInRtl() {
var labelWidth = 0f
composeRule.setContent {
SubtracksTheme {
labelWidth =
rememberTextMeasurer()
.measure(
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
softWrap = false,
).size.width
.toFloat()
LibraryTabs(
pagerState =
remember {
PagerState(
currentPage = LibraryTab.Playlists.ordinal,
pageCount = { LibraryTab.entries.size },
)
},
onTabSelected = {},
onOpenSettings = {},
artwork = null,
)
}
}
composeRule.waitForIdle()
composeRule
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
.assertWidthIsEqualTo(40.dp)
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
}
@Test
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
fun movingToALongTabScrollsItIntoView() {
val selectedTab = mutableStateOf(LibraryTab.Home)
var labelWidth = 0f
composeRule.setContent {
SubtracksTheme {
labelWidth =
rememberTextMeasurer()
.measure(
AnnotatedString(composeRule.activity.getString(LibraryTab.Playlists.label)),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
softWrap = false,
).size.width
.toFloat()
LibraryScreen(
selectedTab = selectedTab.value,
onTabSelected = { selectedTab.value = it },
albums = remember { flowOf(PagingData.empty<Album>()) },
artists = remember { flowOf(PagingData.empty<Artist>()) },
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
coverArt = { _, _ -> null },
onAlbumClick = {},
onArtistClick = {},
onPlaylistClick = {},
onSync = {},
onOpenSettings = {},
)
}
}
composeRule.waitForIdle()
composeRule.runOnIdle { selectedTab.value = LibraryTab.Playlists }
val expected = with(composeRule.density) { TAB_LABEL_CHROME.toPx() } + labelWidth
composeRule.waitUntil(5_000) { tabBounds(LibraryTab.Playlists).width >= expected - 1f }
composeRule
.onNodeWithContentDescription(composeRule.activity.getString(R.string.navigation_tabs_settings))
.assertWidthIsEqualTo(40.dp)
assertTabFullyVisible(LibraryTab.Playlists, labelWidth)
}
@Test
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
fun returningToTheFirstTabLeavesAGapAtTheEdge() {
val selectedTab = mutableStateOf(LibraryTab.Playlists)
composeRule.setContent {
SubtracksTheme {
LibraryScreen(
selectedTab = selectedTab.value,
onTabSelected = { selectedTab.value = it },
albums = remember { flowOf(PagingData.empty<Album>()) },
artists = remember { flowOf(PagingData.empty<Artist>()) },
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
coverArt = { _, _ -> null },
onAlbumClick = {},
onArtistClick = {},
onPlaylistClick = {},
onSync = {},
onOpenSettings = {},
)
}
}
composeRule.waitForIdle()
composeRule.runOnIdle { selectedTab.value = LibraryTab.Home }
val expectedGap = with(composeRule.density) { TAB_EDGE_GAP.toPx() }
composeRule.waitUntil(5_000) { tabBounds(LibraryTab.Home).left <= expectedGap + 1f }
assertEquals(
"the first tab should keep a gap from the screen edge",
expectedGap,
tabBounds(LibraryTab.Home).left,
1f,
)
}
private fun assertTabFullyVisible(
tab: LibraryTab,
labelWidth: Float,
) {
val bounds = tabBounds(tab)
val expected = with(composeRule.density) { TAB_LABEL_CHROME.toPx() } + labelWidth
assertEquals("the selected tab should be shown in full, but was $bounds", expected, bounds.width, 1f)
}
@Test
@Config(qualifiers = "de-rDE-w320dp-h640dp-xhdpi")
fun theOfflineIndicatorGoesOnline() {
var exited = false
composeRule.setContent {
SubtracksTheme {
LibraryScreen(
selectedTab = LibraryTab.Playlists,
onTabSelected = {},
albums = remember { flowOf(PagingData.empty<Album>()) },
artists = remember { flowOf(PagingData.empty<Artist>()) },
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
coverArt = { _, _ -> null },
onAlbumClick = {},
onArtistClick = {},
onPlaylistClick = {},
onSync = {},
onOpenSettings = {},
offline = true,
onExitOffline = { exited = true },
)
}
}
composeRule.waitForIdle()
composeRule
.onNodeWithContentDescription(composeRule.activity.getString(R.string.offline_mode_status))
.performClick()
assertTrue("the offline indicator should leave offline mode", exited)
}
private fun tabBounds(tab: LibraryTab) =
composeRule
.onNodeWithContentDescription(composeRule.activity.getString(tab.label))
.fetchSemanticsNode()
.boundsInRoot
private val settledLoadStates = private val settledLoadStates =
LoadStates( LoadStates(
refresh = LoadState.NotLoading(endOfPaginationReached = true), refresh = LoadState.NotLoading(endOfPaginationReached = true),
@@ -378,3 +581,6 @@ class LibraryScreenTest {
) )
} }
} }
private val TAB_LABEL_CHROME = 56.dp
private val TAB_EDGE_GAP = 8.dp
@@ -0,0 +1,182 @@
package com.subtracks.ui.components
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ArtistArtTest {
@Test
fun providerImageWinsOverArtistAndAlbumArt() {
val calls = mutableListOf<String>()
val art =
resolveArtistArt(
artist = artist(artistImageUrl = "https://img/artist"),
coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } },
artistImage = { id, url ->
calls += "image:$id:$url"
CoverArtRef("https://img/artist", "cache:image:$id")
},
offline = false,
forIcon = true,
)
assertEquals(CoverArtRef("https://img/artist", "cache:image:ar1"), art.image)
assertTrue(art.covers.isEmpty())
assertEquals(listOf("image:ar1:https://img/artist"), calls)
}
@Test
fun artistCoverArtWinsOverMaterializedAlbumArt() {
val thumbnails = mutableListOf<Boolean>()
val art =
resolveArtistArt(
artist = artist(coverArt = "own", coverArts = listOf("a", "b")),
coverArt = { id, thumbnail ->
thumbnails += thumbnail
id?.let { CoverArtRef(it, "cache:$it:$thumbnail") }
},
artistImage = { _, _ -> null },
offline = false,
forIcon = true,
)
assertEquals(CoverArtRef("own", "cache:own:true"), art.image)
assertTrue(art.covers.isEmpty())
assertTrue(thumbnails.all { it })
}
@Test
fun iconUsesThumbnailsAndLimitsTheGridToFourCovers() {
val thumbnails = mutableListOf<Boolean>()
val art =
resolveArtistArt(
artist = artist(coverArts = listOf("a", "b", "c", "d", "e")),
coverArt = { id, thumbnail ->
thumbnails += thumbnail
id?.let { CoverArtRef(it, "cache:$it") }
},
artistImage = { _, _ -> null },
offline = false,
forIcon = true,
)
assertEquals(listOf("a", "b", "c", "d"), art.covers.map { it.url })
assertEquals(listOf(true, true, true, true), thumbnails)
}
@Test
fun aSingleHeroCoverLoadsFullSizeWhileMultipleCoversUseThumbnails() {
val thumbnails = mutableListOf<Boolean>()
val coverArt = { id: String?, thumbnail: Boolean ->
thumbnails += thumbnail
id?.let { CoverArtRef(it, "cache:$it") }
}
val artistImage: (String, String?) -> CoverArtRef? = { _, _ -> null }
val single =
resolveArtistArt(
artist(coverArts = listOf("a")),
coverArt,
artistImage,
offline = false,
forIcon = false,
)
assertEquals(listOf(true, false), thumbnails)
assertEquals("a", single.thumbnail?.url)
thumbnails.clear()
resolveArtistArt(artist(coverArts = listOf("a", "b")), coverArt, artistImage, offline = false, forIcon = false)
assertEquals(listOf(true, true), thumbnails)
}
@Test
fun offlineUsesDownloadedCoversAndNeverOnlineOnlyOnes() {
val requested = mutableListOf<String>()
val art =
resolveArtistArt(
artist =
artist(
coverArt = "online-own",
coverArts = listOf("online-a", "online-b"),
downloadedCoverArts = listOf("downloaded-a", "downloaded-b"),
),
coverArt = { id, _ ->
requested += id ?: "null"
id?.takeIf { it.startsWith("downloaded") }?.let { CoverArtRef(it, "cache:$it") }
},
artistImage = { _, _ -> null },
offline = true,
forIcon = true,
)
assertEquals(listOf("downloaded-a", "downloaded-b"), art.covers.map { it.url })
assertEquals(setOf("online-own", "downloaded-a", "downloaded-b"), requested.toSet())
}
@Test
fun theProviderAndArtistImagesCarryTheNextSourceAsAFallback() {
val provider =
resolveArtistArt(
artist = artist(coverArt = "own", artistImageUrl = "https://img/artist", coverArts = listOf("a", "b")),
coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } },
artistImage = { _, _ -> CoverArtRef("https://img/artist", "cache:image") },
offline = false,
forIcon = true,
)
assertEquals(CoverArtRef("own", "cache:own"), provider.fallback?.image)
assertEquals(
listOf("a", "b"),
provider.fallback
?.fallback
?.covers
?.map { it.url },
)
val own =
resolveArtistArt(
artist = artist(coverArt = "own", coverArts = listOf("a", "b")),
coverArt = { id, _ -> id?.let { CoverArtRef(it, "cache:$it") } },
artistImage = { _, _ -> null },
offline = false,
forIcon = true,
)
assertEquals(listOf("a", "b"), own.fallback?.covers?.map { it.url })
}
@Test
fun noArtResolvesToAnEmptySelectionInsteadOfAStaleFallback() {
val art =
resolveArtistArt(
artist = artist(coverArt = "online-own", coverArts = listOf("a")),
coverArt = { _, _ -> null },
artistImage = { _, _ -> null },
offline = true,
forIcon = true,
)
assertNull(art.image)
assertTrue(art.covers.isEmpty())
}
private fun artist(
coverArt: String? = null,
artistImageUrl: String? = null,
coverArts: List<String> = emptyList(),
downloadedCoverArts: List<String> = emptyList(),
) = Artist(
sourceId = 1,
id = "ar1",
name = "Artist",
albumCount = 0,
starred = null,
coverArt = coverArt,
artistImageUrl = artistImageUrl,
coverArts = coverArts,
downloadedCoverArts = downloadedCoverArts,
)
}
@@ -0,0 +1,83 @@
package com.subtracks.ui.components
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HeroDetailScaffoldRestoreTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun aRestoredScreenWaitsForItsRowsBeforeRestoringTheScrollPosition() {
var visible by mutableStateOf(true)
var rows by mutableIntStateOf(100)
composeRule.setContent {
SubtracksTheme {
val holder = rememberSaveableStateHolder()
if (visible) {
holder.SaveableStateProvider("detail") {
HeroDetailScaffold(
artwork = null,
title = "Album",
onBack = {},
header = { _, _ -> Box(Modifier.height(200.dp)) },
content = { rowModifier -> rowItems(rows, rowModifier) },
contentReady = rows > 0,
)
}
}
}
}
composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Item 20"))
composeRule.onNodeWithText("Item 20").assertIsDisplayed()
composeRule.runOnIdle { visible = false }
composeRule.runOnIdle {
rows = 0
visible = true
}
composeRule.waitForIdle()
composeRule.runOnIdle { rows = 100 }
composeRule.waitForIdle()
composeRule.onNodeWithText("Item 20").assertIsDisplayed()
}
private fun LazyListScope.rowItems(
count: Int,
modifier: Modifier,
) {
repeat(count) { index ->
item(key = index) {
Box(modifier.height(48.dp)) {
Text("Item $index")
}
}
}
}
}
@@ -6,15 +6,18 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filter
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.ArtistRef import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.ui.theme.SubtracksTheme import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -44,34 +47,69 @@ class ItemContextMenuArtistTest {
) )
@Test @Test
fun showsOneEntryPerArtistAndOpensTheChosenOne() { fun pluralArtistsShareOneEntryThatOpensTheChooser() {
var opened: String? = null var opened: List<ArtistRef>? = null
composeRule.setContent { composeRule.setContent {
SubtracksTheme { SubtracksTheme {
var show by remember { mutableStateOf(true) } var show by remember { mutableStateOf(true) }
if (show) { if (show) {
ItemContextMenu( ItemContextMenu(
target = MenuTarget.Song(song), target = MenuTarget.Song(song),
actions = ItemActions(viewArtist = { opened = it }), actions = ItemActions(viewArtist = {}),
onDismiss = { show = false }, onDismiss = { show = false },
onChooseArtists = { opened = it },
) )
} }
} }
} }
composeRule.onNodeWithText("A").assertIsDisplayed() val entry = composeRule.onAllNodesWithText("A · B").filterToOne(hasClickAction())
composeRule.onNodeWithText("B").assertIsDisplayed() entry.assertIsDisplayed()
assertTrue(
composeRule
.onAllNodesWithText("A")
.filter(hasClickAction())
.fetchSemanticsNodes()
.isEmpty(),
)
assertTrue(
composeRule
.onAllNodesWithText("B")
.filter(hasClickAction())
.fetchSemanticsNodes()
.isEmpty(),
)
composeRule.mainClock.autoAdvance = false composeRule.mainClock.autoAdvance = false
composeRule.onNodeWithText("B").performClick() entry.performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals("ar2", opened) assertEquals(listOf("ar1", "ar2"), opened?.map { it.id })
composeRule.mainClock.advanceTimeBy(1_000)
} }
@Test @Test
fun longMenusScrollToTheLastArtist() { fun aSingleArtistOpensDirectly() {
var opened: String? = null var opened: String? = null
composeRule.setContent {
SubtracksTheme {
ItemContextMenu(
target = MenuTarget.Song(song.copy(artists = listOf(ArtistRef("ar1", "A")))),
actions = ItemActions(viewArtist = { opened = it }),
onDismiss = {},
)
}
}
composeRule.mainClock.autoAdvance = false
composeRule.onAllNodesWithText("A").filterToOne(hasClickAction()).performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals("ar1", opened)
}
@Test
fun manyArtistsStillShareOneEntry() {
var opened: List<ArtistRef>? = null
composeRule.setContent { composeRule.setContent {
SubtracksTheme { SubtracksTheme {
ItemContextMenu( ItemContextMenu(
@@ -79,18 +117,23 @@ class ItemContextMenuArtistTest {
MenuTarget.Song( MenuTarget.Song(
song.copy(artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }), song.copy(artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }),
), ),
actions = ItemActions(viewArtist = { opened = it }), actions = ItemActions(viewArtist = {}),
onDismiss = {}, onDismiss = {},
onChooseArtists = { opened = it },
) )
} }
} }
composeRule.onNodeWithText("Artist 20").performScrollTo().assertIsDisplayed() val entry =
composeRule
.onAllNodesWithText((1..20).joinToString(" · ") { "Artist $it" })
.filterToOne(hasClickAction())
entry.assertIsDisplayed()
composeRule.mainClock.autoAdvance = false composeRule.mainClock.autoAdvance = false
composeRule.onNodeWithText("Artist 20").performClick() entry.performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals("ar20", opened) assertEquals(20, opened?.size)
composeRule.mainClock.advanceTimeBy(1_000)
} }
} }
@@ -0,0 +1,118 @@
package com.subtracks.ui.components
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ItemContextMenuInfoTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private val album =
Album(
sourceId = 1,
id = "al1",
artistId = "ar1",
name = "Album",
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
)
@Test
fun anAlbumMenuOpensItsInfo() {
var opened: MenuTarget? = null
composeRule.setContent {
SubtracksTheme {
ItemContextMenu(
target = MenuTarget.Album(album),
actions = ItemActions(),
onDismiss = {},
onInfo = { opened = it },
)
}
}
composeRule.onNodeWithText("Info").performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals(album, (opened as? MenuTarget.Album)?.album)
}
@Test
fun anArtistMenuOpensItsInfo() {
var opened: MenuTarget? = null
composeRule.setContent {
SubtracksTheme {
ItemContextMenu(
target =
MenuTarget.Artist(
Artist(
sourceId = 1,
id = "ar1",
name = "Artist",
albumCount = 1,
starred = null,
),
),
actions = ItemActions(),
onDismiss = {},
onInfo = { opened = it },
)
}
}
composeRule.onNodeWithText("Info").performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals("ar1", (opened as? MenuTarget.Artist)?.artist?.id)
}
@Test
fun aPlaylistMenuOpensItsInfo() {
var opened: MenuTarget? = null
composeRule.setContent {
SubtracksTheme {
ItemContextMenu(
target =
MenuTarget.Playlist(
Playlist(
sourceId = 1,
id = "pl1",
name = "Playlist",
comment = null,
coverArt = null,
songCount = 1,
created = 0,
),
),
actions = ItemActions(),
onDismiss = {},
onInfo = { opened = it },
)
}
}
composeRule.onNodeWithText("Info").performClick()
composeRule.mainClock.advanceTimeBy(1_000)
assertEquals("pl1", (opened as? MenuTarget.Playlist)?.playlist?.id)
}
}
@@ -0,0 +1,372 @@
package com.subtracks.ui.components
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class LibraryInfoDialogTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun anAlbumShowsEveryArtistAndBothSortNames() {
composeRule.setContent {
SubtracksTheme {
AlbumInfoDialog(
album =
Album(
sourceId = 1,
id = "al1",
artistId = "ar1",
name = "Album",
albumArtist = "A",
created = 0,
coverArt = null,
genre = "Rock",
year = 1999,
starred = null,
songCount = 12,
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
sortName = "Album, The",
albumArtistSort = "A, The",
),
download = null,
onDismiss = {},
)
}
}
composeRule.onNodeWithText("A · B").assertIsDisplayed()
composeRule.onNodeWithText("Album, The").assertIsDisplayed()
composeRule.onNodeWithText("A, The").assertIsDisplayed()
composeRule.onNodeWithText("1999").assertIsDisplayed()
val label = composeRule.onNodeWithText("Album artist sort").fetchSemanticsNode().boundsInRoot
val value = composeRule.onNodeWithText("A, The").fetchSemanticsNode().boundsInRoot
assertTrue("the value must be separated from its label", value.left > label.right)
}
@Test
fun anArtistShowsItsSortNameAndCounts() {
composeRule.setContent {
SubtracksTheme {
ArtistInfoDialog(
artist =
Artist(
sourceId = 1,
id = "ar1",
name = "The Artist",
albumCount = 4,
starred = null,
songCount = 40,
sortName = "Artist, The",
),
onDismiss = {},
)
}
}
composeRule.onNodeWithText("Artist, The").assertIsDisplayed()
composeRule.onNodeWithText("4").assertIsDisplayed()
composeRule.onNodeWithText("40").assertIsDisplayed()
}
@Test
fun anArtistHidesASortNameThatMatchesItsName() {
composeRule.setContent {
SubtracksTheme {
ArtistInfoDialog(
artist =
Artist(
sourceId = 1,
id = "ar1",
name = "The Artist",
albumCount = 1,
starred = null,
sortName = "the artist",
),
onDismiss = {},
)
}
}
composeRule.onNodeWithText("Sort name").assertDoesNotExist()
}
@Test
fun anAlbumHidesSortNamesThatMatchTheRegularNames() {
composeRule.setContent {
SubtracksTheme {
AlbumInfoDialog(
album =
Album(
sourceId = 1,
id = "al1",
artistId = "ar1",
name = "Album",
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
sortName = "album",
albumArtistSort = "artist",
),
download = null,
onDismiss = {},
)
}
}
composeRule.onNodeWithText("Sort title").assertDoesNotExist()
composeRule.onNodeWithText("Album artist sort").assertDoesNotExist()
}
@Test
fun anAlbumsDatesAreListedTogether() {
composeRule.setContent {
SubtracksTheme {
AlbumInfoDialog(
album =
Album(
sourceId = 1,
id = "al1",
artistId = "ar1",
name = "Album",
albumArtist = "Artist",
created = 86400,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
playCount = 3,
played = 172800,
),
download = null,
onDismiss = {},
)
}
}
val added = composeRule.onNodeWithText("Added").fetchSemanticsNode().boundsInRoot
val lastPlayed = composeRule.onNodeWithText("Last played").fetchSemanticsNode().boundsInRoot
val plays = composeRule.onNodeWithText("Plays").fetchSemanticsNode().boundsInRoot
assertTrue("last played must follow added", lastPlayed.top >= added.bottom)
assertTrue("plays must not sit between the dates", plays.top >= lastPlayed.bottom)
assertEquals(
"no row may sit between added and last played",
lastPlayed.top - added.top,
plays.top - lastPlayed.top,
0.5f,
)
}
@Test
fun aPlaylistShowsOwnerSharingLengthAndDates() {
composeRule.setContent {
SubtracksTheme {
PlaylistInfoDialog(
playlist =
Playlist(
sourceId = 1,
id = "pl1",
name = "Mix",
comment = null,
coverArt = null,
songCount = 12,
created = 1600000000,
changed = 1700000000,
duration = 3725,
owner = "austin",
shared = true,
),
onDismiss = {},
)
}
}
composeRule.onNodeWithText("Owner").assertIsDisplayed()
composeRule.onNodeWithText("austin").assertIsDisplayed()
composeRule.onNodeWithText("Shared").assertIsDisplayed()
composeRule.onNodeWithText("Yes").assertIsDisplayed()
composeRule.onNodeWithText("12").assertIsDisplayed()
composeRule.onNodeWithText("1:02:05").assertIsDisplayed()
composeRule.onNodeWithText("Created").assertIsDisplayed()
composeRule.onNodeWithText("Updated").assertIsDisplayed()
}
@Test
fun aSingleArtistTrackUsesSingularLabels() {
composeRule.setContent {
SubtracksTheme {
SongInfoDialog(
song = song(artists = listOf(ArtistRef("ar1", "Alpha")), artistSort = "Beta"),
download = null,
localFile = null,
streamEncoding = null,
onDismiss = {},
genres = emptyList(),
)
}
}
composeRule.onNodeWithText("Artist").assertIsDisplayed()
composeRule.onNodeWithText("Artist sort").assertIsDisplayed()
composeRule.onNodeWithText("Artists").assertDoesNotExist()
composeRule.onNodeWithText("Artists sort").assertDoesNotExist()
}
@Test
fun duplicateAndBlankArtistRefsStaySingular() {
composeRule.setContent {
SubtracksTheme {
SongInfoDialog(
song =
song(
artists = listOf(ArtistRef("ar1", "Alpha"), ArtistRef("ar2", "Alpha"), ArtistRef("ar3", "")),
artistSort = "Beta",
),
download = null,
localFile = null,
streamEncoding = null,
onDismiss = {},
genres = emptyList(),
)
}
}
composeRule.onNodeWithText("Artist").assertIsDisplayed()
composeRule.onNodeWithText("Artists").assertDoesNotExist()
}
@Test
fun aMixedArtistTrackUsesPluralLabelsForBothRows() {
composeRule.setContent {
SubtracksTheme {
SongInfoDialog(
song =
song(
artists = listOf(ArtistRef("ar1", "Artist A"), ArtistRef("ar2", "Artist B")),
artistSort = "Alpha",
),
download = null,
localFile = null,
streamEncoding = null,
onDismiss = {},
genres = emptyList(),
)
}
}
composeRule.onNodeWithText("Artists").assertIsDisplayed()
composeRule.onNodeWithText("Artists sort").assertIsDisplayed()
composeRule.onNodeWithText("Artist").assertDoesNotExist()
composeRule.onNodeWithText("Artist sort").assertDoesNotExist()
composeRule.onNodeWithText("Alpha").assertIsDisplayed()
}
@Test
fun aTrackHidesATitleAndArtistSortThatMatchTheRegularNames() {
composeRule.setContent {
SubtracksTheme {
SongInfoDialog(
song = song(artists = listOf(ArtistRef("ar1", "Artist")), sortName = "song", artistSort = "artist"),
download = null,
localFile = null,
streamEncoding = null,
onDismiss = {},
genres = emptyList(),
)
}
}
composeRule.onNodeWithText("Sort title").assertDoesNotExist()
composeRule.onNodeWithText("Artist sort").assertDoesNotExist()
}
@Test
fun aMixedArtistTrackWithoutASortShowsOnlyTheArtistsRow() {
composeRule.setContent {
SubtracksTheme {
SongInfoDialog(
song =
song(
artists = listOf(ArtistRef("ar1", "Artist A"), ArtistRef("ar2", "Artist B")),
artistSort = "",
),
download = null,
localFile = null,
streamEncoding = null,
onDismiss = {},
genres = emptyList(),
)
}
}
composeRule.onNodeWithText("Artists").assertIsDisplayed()
composeRule.onNodeWithText("Artist A · Artist B").assertIsDisplayed()
composeRule.onNodeWithText("Artists sort").assertDoesNotExist()
}
@Test
fun aTrackWithOnlyAnArtistSortShowsOnlyTheSortRow() {
composeRule.setContent {
SubtracksTheme {
SongInfoDialog(
song = song(artists = emptyList(), artist = null, artistSort = "Alpha"),
download = null,
localFile = null,
streamEncoding = null,
onDismiss = {},
genres = emptyList(),
)
}
}
composeRule.onNodeWithText("Artist sort").assertIsDisplayed()
composeRule.onNodeWithText("Alpha").assertIsDisplayed()
composeRule.onNodeWithText("Artist").assertDoesNotExist()
composeRule.onNodeWithText("Artists").assertDoesNotExist()
}
private fun song(
artists: List<ArtistRef>,
artist: String? = artists.firstOrNull()?.name,
sortName: String = "Song",
artistSort: String = artist.orEmpty(),
) = Song(
sourceId = 1,
id = "s1",
albumId = "al1",
artistId = artists.firstOrNull()?.id,
title = "Song",
album = "Album",
artist = artist,
duration = 200,
track = 1,
disc = 1,
starred = null,
artists = artists,
sortName = sortName,
artistSort = artistSort,
)
}
@@ -90,7 +90,13 @@ class DownloadsViewModelTest {
) )
viewModel = viewModel =
DownloadsViewModel( DownloadsViewModel(
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope), LibraryRepository(
db,
sourceRepository,
NetworkServerActionSink(sourceRepository),
fakeUserPreferences(),
scope = repoScope,
),
downloadRepository, downloadRepository,
controller, controller,
"Unknown artist", "Unknown artist",
@@ -0,0 +1,105 @@
package com.subtracks.ui.library
import com.subtracks.data.model.Artist
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ArtistDetailArtTest {
@Test
fun aCreditedArtistWithoutARowUsesTheCreditCovers() {
val resolved =
resolveArtistArtInput(
ArtistArtInput(
artist = null,
artistId = "ar1",
offline = false,
sourceId = 7,
covers = listOf("a", "b"),
),
)
assertEquals(7L, resolved?.sourceId)
assertEquals("ar1", resolved?.id)
assertEquals(listOf("a", "b"), resolved?.coverArts)
}
@Test
fun anOfflineArtistKeepsItsDownloadedCovers() {
val resolved =
resolveArtistArtInput(
ArtistArtInput(
artist = artist(coverArts = listOf("online"), downloadedCoverArts = listOf("downloaded")),
artistId = "ar1",
offline = true,
sourceId = 7,
covers = listOf("online", "other"),
),
)
assertEquals(listOf("online", "other"), resolved?.coverArts)
assertEquals(listOf("downloaded"), resolved?.downloadedCoverArts)
}
@Test
fun anOfflineArtistWithoutDownloadedCoversFallsBackToTheCreditCovers() {
val resolved =
resolveArtistArtInput(
ArtistArtInput(
artist = artist(downloadedCoverArts = emptyList()),
artistId = "ar1",
offline = true,
sourceId = 7,
covers = listOf("a", "b"),
),
)
assertEquals(listOf("a", "b"), resolved?.downloadedCoverArts)
}
@Test
fun anArtistWithMaterializedCoversIsKeptWhenTheCreditQueryIsEmpty() {
val original = artist(coverArts = listOf("materialized"))
val resolved =
resolveArtistArtInput(
ArtistArtInput(
artist = original,
artistId = "ar1",
offline = false,
sourceId = 7,
covers = emptyList(),
),
)
assertEquals(original, resolved)
}
@Test
fun noArtistAndNoCoversClearsTheArt() {
assertNull(
resolveArtistArtInput(
ArtistArtInput(
artist = null,
artistId = "ar1",
offline = false,
sourceId = 7,
covers = emptyList(),
),
),
)
}
private fun artist(
coverArts: List<String> = emptyList(),
downloadedCoverArts: List<String> = emptyList(),
) = Artist(
sourceId = 7,
id = "ar1",
name = "Artist",
albumCount = 1,
starred = null,
coverArts = coverArts,
downloadedCoverArts = downloadedCoverArts,
)
}
@@ -98,7 +98,7 @@ class NowPlayingRouteTest {
artworkFetcher = { ByteArray(0) }, artworkFetcher = { ByteArray(0) },
scope = repoScope, scope = repoScope,
).also { it.start() } ).also { it.start() }
library = LibraryRepository(db, sources, serverActions, scope = repoScope) library = LibraryRepository(db, sources, serverActions, prefs, scope = repoScope)
handle = FakePlayerHandle() handle = FakePlayerHandle()
controller = controller =
PlaybackController( PlaybackController(
@@ -90,7 +90,13 @@ class SearchViewModelTest {
) )
viewModel = viewModel =
SearchViewModel( SearchViewModel(
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope), LibraryRepository(
db,
sourceRepository,
NetworkServerActionSink(sourceRepository),
fakeUserPreferences(),
scope = repoScope,
),
sourceRepository, sourceRepository,
controller, controller,
) )
@@ -12,6 +12,7 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.subtracks.data.prefs.SortNameMode
import com.subtracks.data.prefs.StreamQuality import com.subtracks.data.prefs.StreamQuality
import com.subtracks.ui.theme.SubtracksTheme import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -31,7 +32,7 @@ class SettingsScreenTest {
@Test @Test
fun theKeepScreenOnRowTurnsTheSettingOn() { fun theKeepScreenOnRowTurnsTheSettingOn() {
var requested: Boolean? = null var requested: Boolean? = null
renderSettings(keepScreenOn = false) { requested = it } renderSettings(keepScreenOn = false, onKeepScreenOnChange = { requested = it })
composeRule.onNodeWithText("Keep screen on").performClick() composeRule.onNodeWithText("Keep screen on").performClick()
composeRule.waitForIdle() composeRule.waitForIdle()
@@ -42,7 +43,7 @@ class SettingsScreenTest {
@Test @Test
fun theKeepScreenOnRowTurnsTheSettingOff() { fun theKeepScreenOnRowTurnsTheSettingOff() {
var requested: Boolean? = null var requested: Boolean? = null
renderSettings(keepScreenOn = true) { requested = it } renderSettings(keepScreenOn = true, onKeepScreenOnChange = { requested = it })
composeRule.onNodeWithText("Keep screen on").performClick() composeRule.onNodeWithText("Keep screen on").performClick()
composeRule.waitForIdle() composeRule.waitForIdle()
@@ -53,7 +54,7 @@ class SettingsScreenTest {
@Test @Test
fun theKeepScreenOnSwitchTurnsTheSettingOn() { fun theKeepScreenOnSwitchTurnsTheSettingOn() {
var requested: Boolean? = null var requested: Boolean? = null
renderSettings(keepScreenOn = false) { requested = it } renderSettings(keepScreenOn = false, onKeepScreenOnChange = { requested = it })
composeRule composeRule
.onNode(isToggleable() and hasAnySibling(hasText("Keep screen on")), useUnmergedTree = true) .onNode(isToggleable() and hasAnySibling(hasText("Keep screen on")), useUnmergedTree = true)
@@ -63,9 +64,29 @@ class SettingsScreenTest {
assertEquals(true, requested) assertEquals(true, requested)
} }
@Test
fun theSortNamesRowChangesTheSortNameMode() {
var requested: SortNameMode? = null
renderSettings(
keepScreenOn = false,
onKeepScreenOnChange = {},
onSortNameModeChange = { requested = it },
scrollTo = "Sort names",
)
composeRule.onNodeWithText("Sort names").performClick()
composeRule.onNodeWithText("Use sort names").performClick()
composeRule.onNodeWithText("Done").performClick()
composeRule.waitForIdle()
assertEquals(SortNameMode.SortName, requested)
}
private fun renderSettings( private fun renderSettings(
keepScreenOn: Boolean, keepScreenOn: Boolean,
onKeepScreenOnChange: (Boolean) -> Unit, onKeepScreenOnChange: (Boolean) -> Unit,
onSortNameModeChange: (SortNameMode) -> Unit = {},
scrollTo: String = "Keep screen on",
) { ) {
composeRule.setContent { composeRule.setContent {
SubtracksTheme { SubtracksTheme {
@@ -88,6 +109,7 @@ class SettingsScreenTest {
onDownloadOverMeteredChange = {}, onDownloadOverMeteredChange = {},
onScrobblingChange = {}, onScrobblingChange = {},
onKeepScreenOnChange = onKeepScreenOnChange, onKeepScreenOnChange = onKeepScreenOnChange,
onSortNameModeChange = onSortNameModeChange,
onAddServer = {}, onAddServer = {},
onOpenDownloads = {}, onOpenDownloads = {},
onOpenLicenses = {}, onOpenLicenses = {},
@@ -97,7 +119,7 @@ class SettingsScreenTest {
} }
} }
awaitText("Add server") awaitText("Add server")
composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("Keep screen on")) composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(scrollTo))
} }
private fun awaitText(text: String) { private fun awaitText(text: String) {
+24
View File
@@ -0,0 +1,24 @@
## What's new
- ReplayGain: normalizes loudness from the tags your server reports. Auto, the default, uses album gain for album queues and track gain for playlists, singles and shuffles. Playback settings also offer track gain, album gain, off and a preamp
- Artist pages show an expandable biography, artwork from the server or built from the artist's albums, and separate sections for album credits and appearances
- Sort lists can use regular names (the default), sort names, or sort names only for names without Latin letters
- Improved handling for albums and tracks with multiple artists, including their names in song subtitles, the queue and info dialogs
- Info dialogs in album, artist and playlist menus, listing credited artists, sort names, owner and sharing, and play and download details
- Keep the screen on while now playing is open setting, off by default (thanks @chiangww!)
## Fixed
- Album and artist scroll positions survive navigating away and back
- Search keeps its query and focus when you return
- The library tab strip scrolls, so the settings gear is never squeezed out
- Notification progress for streams of unknown length, such as Opus/Ogg transcodes
- Album grid no longer shifts covers after a scrobble reloads the list
- Now playing screen: artwork wraps with repeat, and cover shadows are no longer clipped
## Performance
- Faster library sync on servers without `search3`
- Artist lists and downloads stay fast on large libraries
**Full changelog:** https://github.com/austinried/subtracks/compare/v3.0.0...v3.1.0
+12
View File
@@ -0,0 +1,12 @@
- ReplayGain: loudness from server tags, with Auto, track, album, off, preamp
- Artist pages: bio, art, credits and appearances
- Sort lists: regular names or sort names
- Improved handling for multi-artist albums/tracks
- Keep the screen on while now playing is open setting, off by default (thanks @chiangww!)
Fixed
- Notification progress for unknown-length streams (Opus/Ogg)
- Now playing artwork and shadow fixes
Release notes: https://github.com/austinried/subtracks/releases/tag/v3.1.0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 513 KiB

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 KiB

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 371 KiB