Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11276c2e1f | ||
|
|
eb55858cc4 | ||
|
|
95ad993996 | ||
|
|
11abd4afbd | ||
|
|
69f41b3459 | ||
|
|
09baaeb77d | ||
|
|
484cc55385 | ||
|
|
599924bdd0 | ||
|
|
d6ff3ee086 | ||
|
|
b2a1716be2 | ||
|
|
51792f836b | ||
|
|
446a35d26e | ||
|
|
b18e0891f6 | ||
|
|
c53f1aef06 | ||
|
|
87e0609c05 | ||
|
|
c3a5382c27 | ||
|
|
e8cbb93714 | ||
|
|
6f1e05d15a | ||
|
|
c165a6cc88 | ||
|
|
18c24ca566 | ||
|
|
8d6f7e2341 | ||
|
|
29935b7bee | ||
|
|
4bfe67ec19 | ||
|
|
060a5f036f | ||
|
|
c169549ef4 | ||
|
|
3bd29f75e6 | ||
|
|
d7a3a41ab2 | ||
|
|
05e318f9ab | ||
|
|
e8fb0ac564 | ||
|
|
7379f3d93e | ||
|
|
f9151843a6 | ||
|
|
2f95a89682 | ||
|
|
319ff4b645 | ||
|
|
d8ac7fefc7 | ||
|
|
f46f43b6cd | ||
|
|
8052e1db76 | ||
|
|
edc8782302 | ||
|
|
3ffe38d988 | ||
|
|
b76328fc26 | ||
|
|
9feaf54cd2 | ||
|
|
72e1b61bff | ||
|
|
bb3abe39b6 | ||
|
|
be08e50314 | ||
|
|
b9a3ae5ed0 | ||
|
|
47787182d0 | ||
|
|
aca2bd4be2 | ||
|
|
677596c21b | ||
|
|
1dbfb027fe | ||
|
|
8765c5d37b | ||
|
|
2da0d61d4c | ||
|
|
2c6af4b126 | ||
|
|
61774a19ca | ||
|
|
0602f835dc | ||
|
|
45fc169c42 | ||
|
|
e487d2e0b1 | ||
|
|
6f1e1612e1 | ||
|
|
185aaabbc5 | ||
|
|
c1a01b9a47 | ||
|
|
fbd4c0c4fe | ||
|
|
c56a6d4f83 |
@@ -40,6 +40,11 @@ runs:
|
||||
path: ~/.gradle/caches
|
||||
key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-v2-
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.m2/repository/org/robolectric
|
||||
key: robolectric-m2-v1-${{ hashFiles('gradle/libs.versions.toml', 'app/src/test/resources/robolectric.properties') }}
|
||||
restore-keys: robolectric-m2-v1-
|
||||
- id: signing
|
||||
if: inputs.sign == 'true'
|
||||
shell: bash
|
||||
|
||||
@@ -50,5 +50,5 @@ jobs:
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: .integration/music
|
||||
key: integration-music-v1
|
||||
key: integration-music-v3
|
||||
- run: nix develop .#integration --command nu tools/integration-test.nu
|
||||
|
||||
@@ -85,7 +85,9 @@ jobs:
|
||||
echo "value=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Check release notes exist
|
||||
run: test -f "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
|
||||
run: |
|
||||
test -f "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
|
||||
test -f "docs/releases/$GITHUB_REF_NAME.md"
|
||||
- name: Write Play service account
|
||||
if: steps.stable.outputs.value == 'true'
|
||||
env:
|
||||
@@ -123,7 +125,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt")
|
||||
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "docs/releases/$GITHUB_REF_NAME.md")
|
||||
if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
|
||||
args+=(--prerelease)
|
||||
fi
|
||||
|
||||
@@ -37,6 +37,7 @@ See [signing/README.md](signing/README.md) for the fingerprints and the commands
|
||||
- **Adaptive Theming:** Colours drawn from the current song's artwork.
|
||||
- **Search:** Fast search across the whole library, with per-list sort and filter controls.
|
||||
- **Playback:** Gapless playback, an editable queue you can reorder and jump through, plus loop and shuffle modes.
|
||||
- **ReplayGain:** Loudness normalization from the tags your server reports, with Auto, track and album modes and a preamp.
|
||||
- **Up Next:** Add items into the currently playing queue without rewriting it.
|
||||
- **Multiple Servers:** Connect several servers and switch between them quickly.
|
||||
- **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching.
|
||||
|
||||
@@ -36,8 +36,8 @@ android {
|
||||
applicationId = "com.subtracks"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 13
|
||||
versionName = "3.0.0"
|
||||
versionCode = 14
|
||||
versionName = "3.1.0"
|
||||
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ android {
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("debug").res.srcDir("src/nightly/res")
|
||||
getByName("debug").res.directories.add("src/nightly/res")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -181,6 +181,8 @@ val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
||||
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
// Robolectric fetches its Android SDK jar itself, outside Gradle's repositories.
|
||||
systemProperty("robolectric.dependency.repo.url", providers.gradleProperty("mavenCentralMirrorUrl").get())
|
||||
if (name.endsWith("UnitTest")) {
|
||||
filter {
|
||||
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
|
||||
|
||||
@@ -56,7 +56,7 @@ class SubsonicSourceIntegrationTest(
|
||||
assertNotNull(kosmo.starred)
|
||||
assertTrue(kosmo.created <= System.currentTimeMillis() / 1000)
|
||||
|
||||
assertNull(albums.first { it.name == "Retroconnaissance EP" }.starred)
|
||||
assertNull(albums.first { it.name == "Retroconnaissance" }.starred)
|
||||
assertNull(albums.first { it.name == "I Don't Know What I'm Doing" }.starred)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class SubsonicSourceIntegrationTest(
|
||||
.filter { it.artistId == artists.first { a -> a.name == "Ugress" }.id }
|
||||
.map { it.name }
|
||||
.sorted()
|
||||
assertEquals(listOf("Kosmonaut", "Retroconnaissance EP"), ugressAlbums)
|
||||
assertEquals(listOf("Kosmonaut", "Retroconnaissance"), ugressAlbums)
|
||||
|
||||
val bradAlbums =
|
||||
albums
|
||||
|
||||
@@ -22,7 +22,7 @@ class SubsonicWriteIntegrationTest(
|
||||
.albums()
|
||||
.toList()
|
||||
.flatten()
|
||||
.first { it.name == "Retroconnaissance EP" }
|
||||
.first { it.name == "Retroconnaissance" }
|
||||
|
||||
source.setStar(StarType.Album, album.id, true)
|
||||
try {
|
||||
@@ -94,7 +94,7 @@ class SubsonicWriteIntegrationTest(
|
||||
.songs()
|
||||
.toList()
|
||||
.flatten()
|
||||
.first { it.album == "Retroconnaissance EP" }
|
||||
.first { it.album == "Retroconnaissance" }
|
||||
|
||||
source.setStar(StarType.Song, song.id, true)
|
||||
try {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.source.subsonic.TestServers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -18,12 +14,7 @@ class MultiSourceSyncIntegrationTest {
|
||||
@Test
|
||||
fun syncsEveryServerIntoOneLibraryWithoutInterference() =
|
||||
runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val db = createTestDatabase()
|
||||
try {
|
||||
TestServers.all.forEachIndexed { index, server ->
|
||||
val id = index + 1L
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||
import com.subtracks.data.source.subsonic.SubsonicSource
|
||||
@@ -28,12 +24,7 @@ class PruneSyncIntegrationTest {
|
||||
val removed = File(musicDir, REMOVED_ALBUM_DIR)
|
||||
check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" }
|
||||
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val db = createTestDatabase()
|
||||
try {
|
||||
db.sourcesDao().upsertSource(
|
||||
Source(id = 1, name = "prune", address = baseUrl, isActive = true, createdAt = 0),
|
||||
@@ -93,7 +84,7 @@ class PruneSyncIntegrationTest {
|
||||
?: error("$name is not set; run the integration harness (tools/integration-test.nu)")
|
||||
|
||||
private companion object {
|
||||
const val REMOVED_ALBUM_DIR = "197"
|
||||
const val REMOVED_ALBUM_NAME = "Retroconnaissance EP"
|
||||
const val REMOVED_ALBUM_DIR = "7GZYpfn8wypaTR00LWeNI4"
|
||||
const val REMOVED_ALBUM_NAME = "Retroconnaissance"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.source.subsonic.TestServers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -19,12 +15,7 @@ class SyncServiceIntegrationTest {
|
||||
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
|
||||
runBlocking {
|
||||
servers.forEach { server ->
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val db = createTestDatabase()
|
||||
try {
|
||||
db.sourcesDao().upsertSource(
|
||||
Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0),
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import androidx.room3.RoomRawQuery
|
||||
|
||||
internal val CREDITED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery())
|
||||
internal val DOWNLOADED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true))
|
||||
internal val DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST =
|
||||
albumCreditRows(AlbumCreditQuery(downloaded = true, artistScoped = true, selectAlbums = true))
|
||||
internal val DOWNLOADED_COVER_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true, coverOnly = true))
|
||||
internal val ARTIST_COVER_CREDITS = albumCreditRows(AlbumCreditQuery(coverOnly = true))
|
||||
internal val ARTIST_COVER_CREDITS_FOR_ARTIST = albumCreditRows(AlbumCreditQuery(coverOnly = true, artistScoped = true))
|
||||
|
||||
private data class AlbumCreditQuery(
|
||||
val artistScoped: Boolean = false,
|
||||
val coverOnly: Boolean = false,
|
||||
val downloaded: Boolean = false,
|
||||
val selectAlbums: Boolean = false,
|
||||
)
|
||||
|
||||
private fun albumCreditRows(query: AlbumCreditQuery): String =
|
||||
if (query.selectAlbums) {
|
||||
listOf(
|
||||
primaryAlbumCredits(query),
|
||||
albumRelationCredits(query),
|
||||
trackRelationCredits(query),
|
||||
trackCredits(query),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
primaryAlbumCredits(query),
|
||||
albumRelationCredits(query),
|
||||
trackCredits(query),
|
||||
trackRelationCredits(query),
|
||||
)
|
||||
}.joinToString(if (query.downloaded) " UNION " else " UNION ALL ")
|
||||
|
||||
private fun primaryAlbumCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM albums "
|
||||
query.downloaded -> "SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums "
|
||||
else -> "SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType FROM albums "
|
||||
},
|
||||
)
|
||||
append("WHERE albums.sourceId = :sourceId AND ${query.directArtist("albums.artistId")} ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND $ALBUM_DOWNLOADED ")
|
||||
}.trim()
|
||||
|
||||
private fun albumRelationCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM album_artists "
|
||||
query.downloaded -> "SELECT albums.id, album_artists.artistId FROM album_artists "
|
||||
else -> "SELECT album_artists.artistId, albums.id, 'album' FROM album_artists "
|
||||
},
|
||||
)
|
||||
append("JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId ")
|
||||
append("WHERE album_artists.sourceId = :sourceId ")
|
||||
if (query.artistScoped) append("AND album_artists.artistId = :artistId ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND $ALBUM_DOWNLOADED ")
|
||||
}.trim()
|
||||
|
||||
private fun trackCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM songs "
|
||||
query.downloaded -> "SELECT songs.albumId, songs.artistId FROM songs "
|
||||
else -> "SELECT songs.artistId, songs.albumId, 'track' FROM songs "
|
||||
},
|
||||
)
|
||||
append(SONG_ALBUM_JOIN)
|
||||
if (query.downloaded) append(TRACK_DOWNLOAD_JOIN)
|
||||
append("WHERE songs.sourceId = :sourceId AND ${query.directArtist("songs.artistId")} ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND sd.status = 'Completed' ")
|
||||
}.trim()
|
||||
|
||||
private fun trackRelationCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM song_artists "
|
||||
query.downloaded -> "SELECT songs.albumId, song_artists.artistId FROM song_artists "
|
||||
else -> "SELECT song_artists.artistId, songs.albumId, 'track' FROM song_artists "
|
||||
},
|
||||
)
|
||||
append("JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId ")
|
||||
append(SONG_ALBUM_JOIN)
|
||||
if (query.downloaded) append(TRACK_DOWNLOAD_JOIN)
|
||||
append("WHERE song_artists.sourceId = :sourceId ")
|
||||
if (query.artistScoped) append("AND song_artists.artistId = :artistId ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND sd.status = 'Completed' ")
|
||||
}.trim()
|
||||
|
||||
private fun AlbumCreditQuery.directArtist(column: String): String = if (artistScoped) "$column = :artistId" else "$column IS NOT NULL"
|
||||
|
||||
private fun AlbumCreditQuery.coverFilter(): String = if (coverOnly) "AND albums.coverArt IS NOT NULL " else ""
|
||||
|
||||
private const val SONG_ALBUM_JOIN = "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId "
|
||||
|
||||
private const val TRACK_DOWNLOAD_JOIN = "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id "
|
||||
|
||||
private const val ALBUM_DOWNLOADED =
|
||||
"EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')"
|
||||
|
||||
internal fun downloadedArtistArtQuery(
|
||||
sourceId: Long,
|
||||
artistIds: List<String>,
|
||||
): RoomRawQuery {
|
||||
val bindings = mutableMapOf<String, Any?>("sourceId" to sourceId)
|
||||
val placeholders =
|
||||
artistIds
|
||||
.mapIndexed { index, artistId ->
|
||||
val name = "artistId$index"
|
||||
bindings[name] = artistId
|
||||
":$name"
|
||||
}.ifEmpty { listOf("NULL") }
|
||||
return rawQuery(DOWNLOADED_ARTIST_ART_UPDATE_SQL.replace(":artistIds", placeholders.joinToString(",")), bindings)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import com.subtracks.log.Log
|
||||
|
||||
@@ -11,7 +12,9 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||
.setDriver(driver)
|
||||
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||
.addCallback(ArtistSearchTriggerOptimization)
|
||||
.addCallback(SqliteLifecycleLogging)
|
||||
.addSubtracksDatabaseCallbacks()
|
||||
.build()
|
||||
}
|
||||
|
||||
internal fun RoomDatabase.Builder<SubtracksDatabase>.addSubtracksDatabaseCallbacks(): RoomDatabase.Builder<SubtracksDatabase> =
|
||||
addCallback(ArtistSearchTriggerOptimization).addCallback(SqliteLifecycleLogging)
|
||||
|
||||
@@ -10,15 +10,21 @@ import androidx.room3.Transaction
|
||||
import androidx.room3.Upsert
|
||||
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumArtistRef
|
||||
import com.subtracks.data.model.AlbumSearch
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.ArtistSearch
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.DiscKey
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSearch
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.PlaylistSongItem
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongArtistRef
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.Source
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -36,25 +42,6 @@ internal const val DOWNLOADED_SONG =
|
||||
"EXISTS (SELECT 1 FROM song_downloads sd WHERE sd.sourceId = playlist_songs.sourceId " +
|
||||
"AND sd.songId = playlist_songs.songId AND sd.status = 'Completed')"
|
||||
|
||||
internal const val ALBUMS_FILTER =
|
||||
"FROM albums WHERE sourceId = :sourceId " +
|
||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
||||
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')) " +
|
||||
"AND (:search = '' " +
|
||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
||||
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
||||
"OR instr(lower(albumArtist), lower(:search)) > 0 " +
|
||||
"OR instr(lower(sortName), lower(:search)) > 0 " +
|
||||
"OR instr(lower(albumArtistSort), lower(:search)) > 0))) "
|
||||
|
||||
internal const val ALBUM_ORDER_BY_NAME = "sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST = "albumArtistSort COLLATE NOCASE, year, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST_REVERSED =
|
||||
"albumArtistSort COLLATE NOCASE DESC, year DESC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
|
||||
@@ -70,6 +57,10 @@ internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, sortName C
|
||||
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||
|
||||
private const val ALBUM_NAME_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id"
|
||||
|
||||
internal const val ARTIST_CREDITED =
|
||||
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
||||
|
||||
@@ -93,124 +84,42 @@ internal const val CREDITED_SONG_ROWS =
|
||||
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId"
|
||||
|
||||
// May repeat an (artistId, albumId) pair when several credit paths reach it; consumers dedupe.
|
||||
internal const val CREDITED_ALBUM_ROWS =
|
||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT album_artists.artistId, albums.id, 'album' " +
|
||||
"FROM album_artists JOIN albums " +
|
||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId, songs.albumId, 'track' " +
|
||||
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
||||
"FROM song_artists JOIN songs " +
|
||||
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_artists.sourceId = :sourceId"
|
||||
|
||||
internal const val ALBUMS_FOR_ARTIST =
|
||||
internal val ALBUMS_FOR_ARTIST =
|
||||
"SELECT albums.* FROM (SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId) credits " +
|
||||
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||
"ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
||||
|
||||
internal const val ALBUM_CREDIT_ALBUM_IDS =
|
||||
internal val ALBUM_CREDIT_ALBUM_IDS =
|
||||
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
|
||||
"WHERE artistId = :artistId AND creditType = 'album'"
|
||||
|
||||
// Download visibility follows the credit path: an album credit includes the album when any song is
|
||||
// downloaded, while a track credit includes it only when the credited track is downloaded.
|
||||
internal const val DOWNLOADED_ALBUM_ROWS =
|
||||
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT albums.id, album_artists.artistId FROM album_artists " +
|
||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId, songs.artistId FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
|
||||
|
||||
private const val DOWNLOADED_ARTIST_ALBUMS =
|
||||
internal 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"
|
||||
|
||||
internal const val ARTIST_ART_UPDATE_SQL =
|
||||
"WITH 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), " +
|
||||
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||
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), " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
|
||||
|
||||
private val ARTIST_COVER_RANKS =
|
||||
"credits AS (SELECT artistId, albumId, " +
|
||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
||||
ARTIST_COVER_RANKS_TAIL
|
||||
|
||||
private val ARTIST_COVER_RANKS_FOR_ARTIST =
|
||||
"credits AS (SELECT artistId, albumId, " +
|
||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
|
||||
ARTIST_COVER_RANKS_TAIL
|
||||
|
||||
internal val ARTIST_ART_UPDATE_SQL =
|
||||
"WITH $ARTIST_COVER_RANKS, " +
|
||||
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||
"SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||
") GROUP BY artistId), " +
|
||||
@@ -235,7 +144,11 @@ internal const val ARTIST_ART_UPDATE_SQL =
|
||||
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
|
||||
|
||||
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||
internal val ARTIST_COVER_ARTS_SQL =
|
||||
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
|
||||
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
|
||||
|
||||
internal val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
|
||||
"WHERE artistId IN (:artistIds)), " +
|
||||
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||
@@ -257,36 +170,14 @@ internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||
"artists.downloadedCoverArts != desired.downloadedCoverArts"
|
||||
|
||||
internal const val ARTISTS_FILTER =
|
||||
"FROM artists WHERE sourceId = :sourceId " +
|
||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
||||
"AND $ARTIST_CREDITED " +
|
||||
"AND (:downloadedFilter = 0 OR EXISTS (" +
|
||||
"SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl " +
|
||||
"WHERE dl.id = artists.id AND dl.status = 'Completed')) " +
|
||||
"AND (:search = '' " +
|
||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
||||
"OR instr(lower(sortName), lower(:search)) > 0))) "
|
||||
internal const val ARTIST_COLUMNS_TAIL =
|
||||
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, " +
|
||||
"artists.downloadedCoverArts, artists.playCount, artists.played, artists.songCount, " +
|
||||
"artists.sortName, artists.credited"
|
||||
|
||||
internal const val ARTIST_COLUMNS =
|
||||
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
|
||||
"artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited"
|
||||
"artists.sourceId, artists.id, artists.name, artists.albumCount, $ARTIST_COLUMNS_TAIL"
|
||||
|
||||
// With the Downloaded filter on, count the albums that actually have a download so the number under
|
||||
// an artist matches the albums its detail screen lists.
|
||||
internal const val ARTISTS_SELECT =
|
||||
"SELECT artists.sourceId, artists.id, artists.name, " +
|
||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
||||
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
|
||||
") ELSE artists.albumCount END AS albumCount, " +
|
||||
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||
"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_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||
@@ -296,26 +187,13 @@ internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, s
|
||||
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, sortName COLLATE NOCASE DESC, id DESC"
|
||||
|
||||
internal const val PLAYLISTS_FILTER =
|
||||
"FROM playlists WHERE sourceId = :sourceId " +
|
||||
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM playlist_songs ps " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId " +
|
||||
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id AND sd.status = 'Completed')) " +
|
||||
"AND (:search = '' " +
|
||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
|
||||
"WHERE playlist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) "
|
||||
private const val ARTIST_NAME_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE = "$ARTIST_NAME_SORT_KEY_MODE, id"
|
||||
|
||||
// With the Downloaded filter on, count the songs that actually have a download so the number under
|
||||
// a playlist matches the songs its detail screen lists.
|
||||
internal const val PLAYLISTS_SELECT =
|
||||
"SELECT playlists.sourceId, playlists.id, playlists.name, playlists.comment, playlists.coverArt, " +
|
||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
||||
"SELECT COUNT(DISTINCT ps.songId) FROM playlist_songs ps " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " +
|
||||
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
|
||||
") ELSE playlists.songCount END AS songCount, " +
|
||||
"playlists.created, playlists.changed, playlists.duration "
|
||||
private const val SONG_NAME_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN songs.title WHEN 'NonLatin' THEN (CASE WHEN songs.title GLOB '*[A-Za-z]*' THEN songs.title ELSE songs.sortName END) ELSE songs.sortName END) COLLATE NOCASE"
|
||||
internal const val SONG_SEARCH_ORDER_SORT_MODE = "$SONG_NAME_SORT_KEY_MODE, songs.id"
|
||||
|
||||
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
||||
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
||||
@@ -344,6 +222,39 @@ internal const val SONG_SEARCH_FILTER =
|
||||
"(length(:search) >= $SEARCH_MIN_LENGTH AND songs.rowid IN (SELECT rowid FROM song_search " +
|
||||
"WHERE song_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"'))"
|
||||
|
||||
internal val ARTIST_AGGREGATES_SQL =
|
||||
"WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " +
|
||||
"credited_albums AS ($CREDITED_ALBUM_ROWS), " +
|
||||
"totals AS (" +
|
||||
"SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||
"FROM credited GROUP BY artistId" +
|
||||
"), album_totals AS (" +
|
||||
"SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||
"FROM credited_albums GROUP BY artistId" +
|
||||
") " +
|
||||
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||
"playCount = c.playCount, played = c.played, credited = c.credited " +
|
||||
"FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " +
|
||||
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " +
|
||||
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
|
||||
"OR EXISTS (SELECT 1 FROM album_artists aa " +
|
||||
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
|
||||
"OR EXISTS (SELECT 1 FROM song_artists sa " +
|
||||
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
|
||||
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
||||
"LEFT JOIN album_totals ON album_totals.id = artists.id " +
|
||||
"WHERE artists.sourceId = :sourceId) c " +
|
||||
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
||||
"AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " +
|
||||
"OR artists.playCount != c.playCount OR artists.played IS NOT c.played " +
|
||||
"OR artists.credited != c.credited)"
|
||||
|
||||
internal val ALBUMS_FOR_ARTIST_DOWNLOADED =
|
||||
"SELECT * FROM ($DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST) ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
||||
|
||||
@Dao
|
||||
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
||||
interface LibraryDao {
|
||||
@@ -376,48 +287,37 @@ interface LibraryDao {
|
||||
)
|
||||
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
||||
|
||||
// Counts, plays and the credited flag come from the shared credit unions. The download-aware
|
||||
// queries are separate because their visibility follows the credit path.
|
||||
@Query(
|
||||
"WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " +
|
||||
"credited_albums AS ($CREDITED_ALBUM_ROWS), " +
|
||||
"totals AS (" +
|
||||
"SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||
"FROM credited GROUP BY artistId" +
|
||||
"), album_totals AS (" +
|
||||
"SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||
"FROM credited_albums GROUP BY artistId" +
|
||||
") " +
|
||||
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||
"playCount = c.playCount, played = c.played, credited = c.credited " +
|
||||
"FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " +
|
||||
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " +
|
||||
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
|
||||
"OR EXISTS (SELECT 1 FROM album_artists aa " +
|
||||
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
|
||||
"OR EXISTS (SELECT 1 FROM song_artists sa " +
|
||||
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
|
||||
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
||||
"LEFT JOIN album_totals ON album_totals.id = artists.id " +
|
||||
"WHERE artists.sourceId = :sourceId) c " +
|
||||
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
||||
"AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " +
|
||||
"OR artists.playCount != c.playCount OR artists.played IS NOT c.played " +
|
||||
"OR artists.credited != c.credited)",
|
||||
)
|
||||
suspend fun recomputeArtistAggregates(sourceId: Long)
|
||||
@RawQuery
|
||||
suspend fun recomputeArtistAggregates(query: RoomRawQuery): Int
|
||||
|
||||
@Query(ARTIST_ART_UPDATE_SQL)
|
||||
suspend fun recomputeArtistArt(sourceId: Long)
|
||||
suspend fun recomputeArtistAggregates(sourceId: Long) {
|
||||
recomputeArtistAggregates(rawQuery(ARTIST_AGGREGATES_SQL, mapOf("sourceId" to sourceId)))
|
||||
}
|
||||
|
||||
@RawQuery
|
||||
suspend fun recomputeArtistArt(query: RoomRawQuery): Int
|
||||
|
||||
suspend fun recomputeArtistArt(sourceId: Long) {
|
||||
recomputeArtistArt(rawQuery(ARTIST_ART_UPDATE_SQL, mapOf("sourceId" to sourceId)))
|
||||
}
|
||||
|
||||
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
|
||||
fun artistCoverArts(query: RoomRawQuery): Flow<List<String>>
|
||||
|
||||
fun artistCoverArts(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<String>> = artistCoverArts(rawQuery(ARTIST_COVER_ARTS_SQL, mapOf("sourceId" to sourceId, "artistId" to artistId)))
|
||||
|
||||
@RawQuery
|
||||
suspend fun recomputeDownloadedArtistArt(query: RoomRawQuery): Int
|
||||
|
||||
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
|
||||
suspend fun recomputeDownloadedArtistArt(
|
||||
sourceId: Long,
|
||||
artistIds: List<String>,
|
||||
)
|
||||
) {
|
||||
recomputeDownloadedArtistArt(downloadedArtistArtQuery(sourceId, artistIds))
|
||||
}
|
||||
|
||||
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
|
||||
suspend fun clearDownloadedArtistArt(sourceId: Long)
|
||||
@@ -566,22 +466,24 @@ interface LibraryDao {
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM albums WHERE sourceId = :sourceId AND $ALBUM_SEARCH_FILTER " +
|
||||
"ORDER BY sortName COLLATE NOCASE, id LIMIT :limit",
|
||||
"ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE LIMIT :limit",
|
||||
)
|
||||
fun searchAlbums(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
limit: Int,
|
||||
sortMode: String = "",
|
||||
): Flow<List<Album>>
|
||||
|
||||
@Query(
|
||||
"SELECT $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(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
limit: Int,
|
||||
sortMode: String = "",
|
||||
): Flow<List<Artist>>
|
||||
|
||||
@Query(
|
||||
@@ -598,12 +500,13 @@ interface LibraryDao {
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND $SONG_SEARCH_FILTER " +
|
||||
"ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit",
|
||||
"ORDER BY $SONG_SEARCH_ORDER_SORT_MODE LIMIT :limit",
|
||||
)
|
||||
fun searchSongs(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
limit: Int,
|
||||
sortMode: String = "",
|
||||
): Flow<List<AlbumSongItem>>
|
||||
|
||||
@Query(
|
||||
@@ -745,239 +648,27 @@ interface LibraryDao {
|
||||
@RawQuery(observedEntities = [Playlist::class])
|
||||
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME")
|
||||
fun albumsByName(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
// These observe every table the statements read; the sets match what Room inferred for the
|
||||
// old downloaded @Query methods, so download and library writes invalidate as before.
|
||||
@RawQuery(observedEntities = [Album::class, Song::class, SongDownload::class, AlbumSearch::class])
|
||||
fun downloadedAlbumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED")
|
||||
fun albumsByNameReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
@RawQuery(
|
||||
observedEntities = [
|
||||
Album::class,
|
||||
Song::class,
|
||||
SongDownload::class,
|
||||
AlbumArtistRef::class,
|
||||
SongArtistRef::class,
|
||||
Artist::class,
|
||||
Source::class,
|
||||
ArtistSearch::class,
|
||||
],
|
||||
)
|
||||
fun downloadedArtistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST")
|
||||
fun albumsByArtist(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED")
|
||||
fun albumsByArtistReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
|
||||
fun albumsByYear(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR_REVERSED")
|
||||
fun albumsByYearReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED")
|
||||
fun albumsByRecentlyAdded(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED_REVERSED")
|
||||
fun albumsByRecentlyAddedReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED")
|
||||
fun albumsByStarred(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED_REVERSED")
|
||||
fun albumsByStarredReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_FREQUENT")
|
||||
fun albumsByFrequent(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_FREQUENT_REVERSED")
|
||||
fun albumsByFrequentReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_RECENT")
|
||||
fun albumsByRecent(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_RECENT_REVERSED")
|
||||
fun albumsByRecentReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME")
|
||||
fun artistsByName(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED")
|
||||
fun artistsByNameReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
|
||||
fun artistsByAlbumCount(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED")
|
||||
fun artistsByAlbumCountReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED")
|
||||
fun artistsByStarred(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED_REVERSED")
|
||||
fun artistsByStarredReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_FREQUENT")
|
||||
fun artistsByFrequent(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_FREQUENT_REVERSED")
|
||||
fun artistsByFrequentReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_RECENT")
|
||||
fun artistsByRecent(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_RECENT_REVERSED")
|
||||
fun artistsByRecentReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME")
|
||||
fun playlistsByName(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME_REVERSED")
|
||||
fun playlistsByNameReversed(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED")
|
||||
fun playlistsByAdded(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED_REVERSED")
|
||||
fun playlistsByAddedReversed(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED")
|
||||
fun playlistsByUpdated(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED_REVERSED")
|
||||
fun playlistsByUpdatedReversed(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Playlist>
|
||||
@RawQuery(observedEntities = [PlaylistSong::class, SongDownload::class, Playlist::class, PlaylistSearch::class])
|
||||
fun downloadedPlaylistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id")
|
||||
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||
@@ -1211,17 +902,21 @@ interface LibraryDao {
|
||||
songId: String,
|
||||
): Flow<List<String>>
|
||||
|
||||
@Query(ALBUMS_FOR_ARTIST)
|
||||
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
|
||||
fun albumsForArtist(query: RoomRawQuery): Flow<List<Album>>
|
||||
|
||||
fun albumsForArtist(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<Album>>
|
||||
): Flow<List<Album>> = albumsForArtist(rawQuery(ALBUMS_FOR_ARTIST, mapOf("sourceId" to sourceId, "artistId" to artistId)))
|
||||
|
||||
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
|
||||
fun albumCreditAlbumIds(query: RoomRawQuery): Flow<List<String>>
|
||||
|
||||
@Query(ALBUM_CREDIT_ALBUM_IDS)
|
||||
fun albumCreditAlbumIds(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<String>>
|
||||
): Flow<List<String>> = albumCreditAlbumIds(rawQuery(ALBUM_CREDIT_ALBUM_IDS, mapOf("sourceId" to sourceId, "artistId" to artistId)))
|
||||
|
||||
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
|
||||
fun discs(
|
||||
@@ -1258,36 +953,16 @@ interface LibraryDao {
|
||||
playlistId: String,
|
||||
): PagingSource<Int, PlaylistSongItem>
|
||||
|
||||
// Download visibility follows the credit path: see DOWNLOADED_ARTIST_ALBUMS.
|
||||
@Query(
|
||||
"SELECT * FROM (" +
|
||||
"SELECT albums.* FROM albums " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT albums.* FROM album_artists " +
|
||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT albums.* FROM song_artists " +
|
||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
||||
"UNION " +
|
||||
"SELECT albums.* FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
|
||||
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
|
||||
@RawQuery(
|
||||
observedEntities = [Album::class, Song::class, SongDownload::class, AlbumArtistRef::class, SongArtistRef::class],
|
||||
)
|
||||
fun albumsForArtistDownloaded(query: RoomRawQuery): Flow<List<Album>>
|
||||
|
||||
fun albumsForArtistDownloaded(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<Album>>
|
||||
): Flow<List<Album>> =
|
||||
albumsForArtistDownloaded(
|
||||
rawQuery(ALBUMS_FOR_ARTIST_DOWNLOADED, mapOf("sourceId" to sourceId, "artistId" to artistId)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,49 +5,163 @@ import androidx.sqlite.SQLiteStatement
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
|
||||
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
|
||||
// the resulting PagingSource is not invalidated by download writes (including progress updates).
|
||||
internal const val ALBUMS_FILTER_BASE =
|
||||
"FROM albums WHERE sourceId = ? " +
|
||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
||||
"AND (? = '' " +
|
||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
||||
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
||||
"OR instr(lower(albumArtist), lower(?)) > 0 " +
|
||||
"OR instr(lower(sortName), lower(?)) > 0 " +
|
||||
"OR instr(lower(albumArtistSort), lower(?)) > 0))) "
|
||||
// The default filters intentionally omit song_downloads: the unfiltered lists do not need the
|
||||
// join, and their paging sources do not observe download writes (including progress updates).
|
||||
internal const val ALBUMS_FILTER = """
|
||||
FROM albums WHERE sourceId = :sourceId
|
||||
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
|
||||
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||
OR instr(lower(albumArtist), lower(:search)) > 0
|
||||
OR instr(lower(sortName), lower(:search)) > 0
|
||||
OR instr(lower(albumArtistSort), lower(:search)) > 0)))
|
||||
"""
|
||||
|
||||
internal const val ARTISTS_FILTER_BASE =
|
||||
"FROM artists WHERE sourceId = ? " +
|
||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
||||
"AND $ARTIST_CREDITED " +
|
||||
"AND (? = '' " +
|
||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
||||
"OR instr(lower(sortName), lower(?)) > 0))) "
|
||||
internal const val ALBUMS_FILTER_DOWNLOADED = """
|
||||
FROM albums WHERE sourceId = :sourceId
|
||||
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||
AND EXISTS (SELECT 1 FROM songs
|
||||
JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id
|
||||
WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
|
||||
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||
OR instr(lower(albumArtist), lower(:search)) > 0
|
||||
OR instr(lower(sortName), lower(:search)) > 0
|
||||
OR instr(lower(albumArtistSort), lower(:search)) > 0)))
|
||||
"""
|
||||
|
||||
internal const val PLAYLISTS_FILTER_BASE =
|
||||
"FROM playlists WHERE sourceId = ? " +
|
||||
"AND (? = '' " +
|
||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
|
||||
"WHERE playlist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
|
||||
internal const val ARTISTS_FILTER = """
|
||||
FROM artists WHERE sourceId = :sourceId
|
||||
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||
AND $ARTIST_CREDITED
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM artist_search
|
||||
WHERE artist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||
OR instr(lower(sortName), lower(:search)) > 0)))
|
||||
"""
|
||||
|
||||
internal fun albumsDefaultQuery(
|
||||
internal const val ARTISTS_FILTER_DOWNLOADED = """
|
||||
FROM artists WHERE sourceId = :sourceId
|
||||
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||
AND $ARTIST_CREDITED
|
||||
AND EXISTS (SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl
|
||||
WHERE dl.id = artists.id AND dl.status = 'Completed')
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM artist_search
|
||||
WHERE artist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||
OR instr(lower(sortName), lower(:search)) > 0)))
|
||||
"""
|
||||
|
||||
// The downloaded list counts the albums that actually have a download so the number under an artist
|
||||
// matches the albums its detail screen lists.
|
||||
internal val ARTIST_COLUMNS_DOWNLOADED = """
|
||||
artists.sourceId, artists.id, artists.name,
|
||||
(SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)) AS albumCount,
|
||||
$ARTIST_COLUMNS_TAIL
|
||||
"""
|
||||
|
||||
internal const val PLAYLISTS_FILTER = """
|
||||
FROM playlists WHERE sourceId = :sourceId
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM playlist_search
|
||||
WHERE playlist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND instr(lower(name), lower(:search)) > 0))
|
||||
"""
|
||||
|
||||
internal const val PLAYLISTS_FILTER_DOWNLOADED = """
|
||||
FROM playlists WHERE sourceId = :sourceId
|
||||
AND EXISTS (SELECT 1 FROM playlist_songs ps
|
||||
JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId
|
||||
WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id AND sd.status = 'Completed')
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM playlist_search
|
||||
WHERE playlist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND instr(lower(name), lower(:search)) > 0))
|
||||
"""
|
||||
|
||||
// The downloaded list counts the songs that actually have a download so the number under a
|
||||
// playlist matches the songs its detail screen lists.
|
||||
internal const val PLAYLISTS_COLUMNS_DOWNLOADED = """
|
||||
playlists.sourceId, playlists.id, playlists.name, playlists.comment, playlists.coverArt,
|
||||
(SELECT COUNT(DISTINCT ps.songId)
|
||||
FROM playlist_songs ps
|
||||
JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed'
|
||||
WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id
|
||||
) AS songCount,
|
||||
playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared
|
||||
"""
|
||||
|
||||
internal fun albumsPageQuery(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
sort: AlbumSort,
|
||||
descending: Boolean,
|
||||
): RoomRawQuery {
|
||||
val order = albumOrder(sort, descending)
|
||||
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||
}
|
||||
}
|
||||
downloaded: Boolean,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): RoomRawQuery =
|
||||
rawQuery(
|
||||
sql = "SELECT * ${if (downloaded) ALBUMS_FILTER_DOWNLOADED else ALBUMS_FILTER} ORDER BY ${albumOrder(
|
||||
sort,
|
||||
descending,
|
||||
sortNameMode,
|
||||
)}",
|
||||
bindings =
|
||||
mapOf(
|
||||
"sourceId" to sourceId,
|
||||
"starredFilter" to starredFilter,
|
||||
"search" to search,
|
||||
),
|
||||
)
|
||||
|
||||
internal fun artistsPageQuery(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
sort: ArtistSort,
|
||||
descending: Boolean,
|
||||
downloaded: Boolean,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): RoomRawQuery =
|
||||
rawQuery(
|
||||
sql =
|
||||
"SELECT ${if (downloaded) ARTIST_COLUMNS_DOWNLOADED else ARTIST_COLUMNS} " +
|
||||
"${if (downloaded) ARTISTS_FILTER_DOWNLOADED else ARTISTS_FILTER} " +
|
||||
"ORDER BY ${artistOrder(sort, descending, sortNameMode)}",
|
||||
bindings =
|
||||
mapOf(
|
||||
"sourceId" to sourceId,
|
||||
"starredFilter" to starredFilter,
|
||||
"search" to search,
|
||||
),
|
||||
)
|
||||
|
||||
internal fun playlistsPageQuery(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
sort: PlaylistSort,
|
||||
descending: Boolean,
|
||||
downloaded: Boolean,
|
||||
): RoomRawQuery =
|
||||
rawQuery(
|
||||
sql =
|
||||
"SELECT ${if (downloaded) PLAYLISTS_COLUMNS_DOWNLOADED else "*"} " +
|
||||
"${if (downloaded) PLAYLISTS_FILTER_DOWNLOADED else PLAYLISTS_FILTER} " +
|
||||
"ORDER BY ${playlistOrder(sort, descending)}",
|
||||
bindings =
|
||||
mapOf(
|
||||
"sourceId" to sourceId,
|
||||
"search" to search,
|
||||
),
|
||||
)
|
||||
|
||||
internal fun rediscoverAlbumsQuery(
|
||||
sourceId: Long,
|
||||
@@ -55,93 +169,126 @@ internal fun rediscoverAlbumsQuery(
|
||||
search: String,
|
||||
cutoff: Long,
|
||||
): RoomRawQuery =
|
||||
RoomRawQuery(
|
||||
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
|
||||
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
||||
) { stmt ->
|
||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||
stmt.bindLong(13, cutoff)
|
||||
}
|
||||
rawQuery(
|
||||
sql =
|
||||
"SELECT * $ALBUMS_FILTER AND played IS NOT NULL AND played < :cutoff " +
|
||||
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
||||
bindings =
|
||||
mapOf(
|
||||
"sourceId" to sourceId,
|
||||
"starredFilter" to starredFilter,
|
||||
"search" to search,
|
||||
"cutoff" to cutoff,
|
||||
),
|
||||
)
|
||||
|
||||
private fun SQLiteStatement.bindAlbumFilters(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
// RoomRawQuery takes a positional bind function, but SQLite gives a repeated named parameter one
|
||||
// index, so the SQL can keep its names and only the first-appearance order needs binding.
|
||||
internal fun rawQuery(
|
||||
sql: String,
|
||||
bindings: Map<String, Any?>,
|
||||
): RoomRawQuery {
|
||||
val values =
|
||||
Regex(":(\\w+)")
|
||||
.findAll(sql)
|
||||
.map { it.groupValues[1] }
|
||||
.distinct()
|
||||
.map(bindings::getValue)
|
||||
.toList()
|
||||
return RoomRawQuery(sql) { statement ->
|
||||
values.forEachIndexed { index, value -> statement.bind(index + 1, value) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun SQLiteStatement.bind(
|
||||
index: Int,
|
||||
value: Any?,
|
||||
) {
|
||||
bindLong(1, sourceId)
|
||||
bindInt(2, starredFilter)
|
||||
bindInt(3, starredFilter)
|
||||
bindInt(4, starredFilter)
|
||||
bindText(5, search)
|
||||
bindText(6, search)
|
||||
bindText(7, search)
|
||||
bindText(8, search)
|
||||
bindText(9, search)
|
||||
bindText(10, search)
|
||||
bindText(11, search)
|
||||
bindText(12, search)
|
||||
}
|
||||
|
||||
internal fun artistsDefaultQuery(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
sort: ArtistSort,
|
||||
descending: Boolean,
|
||||
): RoomRawQuery {
|
||||
val order = artistOrder(sort, descending)
|
||||
return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindLong(1, sourceId)
|
||||
stmt.bindInt(2, starredFilter)
|
||||
stmt.bindInt(3, starredFilter)
|
||||
stmt.bindInt(4, starredFilter)
|
||||
stmt.bindText(5, search)
|
||||
stmt.bindText(6, search)
|
||||
stmt.bindText(7, search)
|
||||
stmt.bindText(8, search)
|
||||
stmt.bindText(9, search)
|
||||
stmt.bindText(10, search)
|
||||
when (value) {
|
||||
null -> bindNull(index)
|
||||
is Int -> bindLong(index, value.toLong())
|
||||
is Long -> bindLong(index, value)
|
||||
is String -> bindText(index, value)
|
||||
else -> error("Unsupported SQL parameter: ${value::class.qualifiedName}")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun playlistsDefaultQuery(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
sort: PlaylistSort,
|
||||
descending: Boolean,
|
||||
): RoomRawQuery {
|
||||
val order = playlistOrder(sort, descending)
|
||||
return RoomRawQuery("SELECT * $PLAYLISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindLong(1, sourceId)
|
||||
stmt.bindText(2, search)
|
||||
stmt.bindText(3, search)
|
||||
stmt.bindText(4, search)
|
||||
stmt.bindText(5, search)
|
||||
stmt.bindText(6, search)
|
||||
internal fun albumNameSortKey(mode: SortNameMode): String =
|
||||
when (mode) {
|
||||
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||
}
|
||||
|
||||
internal fun albumArtistSortKey(mode: SortNameMode): String =
|
||||
when (mode) {
|
||||
SortNameMode.SortName -> {
|
||||
"albumArtistSort COLLATE NOCASE"
|
||||
}
|
||||
|
||||
SortNameMode.Name -> {
|
||||
"albumArtist COLLATE NOCASE"
|
||||
}
|
||||
|
||||
SortNameMode.NonLatin -> {
|
||||
"(CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) COLLATE NOCASE"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun artistNameSortKey(mode: SortNameMode): String =
|
||||
when (mode) {
|
||||
SortNameMode.SortName -> "sortName COLLATE NOCASE"
|
||||
SortNameMode.Name -> "name COLLATE NOCASE"
|
||||
SortNameMode.NonLatin -> "(CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) COLLATE NOCASE"
|
||||
}
|
||||
}
|
||||
|
||||
private fun albumOrder(
|
||||
sort: AlbumSort,
|
||||
descending: Boolean,
|
||||
mode: SortNameMode,
|
||||
): String =
|
||||
when (sort) {
|
||||
AlbumSort.Name -> if (descending) ALBUM_ORDER_BY_NAME_REVERSED else ALBUM_ORDER_BY_NAME
|
||||
AlbumSort.Artist -> if (descending) ALBUM_ORDER_BY_ARTIST_REVERSED else ALBUM_ORDER_BY_ARTIST
|
||||
AlbumSort.Year -> if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
||||
AlbumSort.Added -> if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
||||
AlbumSort.Starred -> if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
||||
AlbumSort.Frequent -> if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
||||
AlbumSort.Recent -> if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
||||
AlbumSort.Name -> {
|
||||
val name = albumNameSortKey(mode)
|
||||
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||
}
|
||||
|
||||
AlbumSort.Artist -> {
|
||||
val artist = albumArtistSortKey(mode)
|
||||
val name = albumNameSortKey(mode)
|
||||
if (descending) "$artist DESC, year DESC, $name DESC, id DESC" else "$artist, year, $name, id"
|
||||
}
|
||||
|
||||
AlbumSort.Year -> {
|
||||
if (descending) ALBUM_ORDER_BY_YEAR_REVERSED else ALBUM_ORDER_BY_YEAR
|
||||
}
|
||||
|
||||
AlbumSort.Added -> {
|
||||
if (descending) ALBUM_ORDER_BY_ADDED_REVERSED else ALBUM_ORDER_BY_ADDED
|
||||
}
|
||||
|
||||
AlbumSort.Starred -> {
|
||||
if (descending) ALBUM_ORDER_BY_STARRED_REVERSED else ALBUM_ORDER_BY_STARRED
|
||||
}
|
||||
|
||||
AlbumSort.Frequent -> {
|
||||
if (descending) ALBUM_ORDER_BY_FREQUENT_REVERSED else ALBUM_ORDER_BY_FREQUENT
|
||||
}
|
||||
|
||||
AlbumSort.Recent -> {
|
||||
if (descending) ALBUM_ORDER_BY_RECENT_REVERSED else ALBUM_ORDER_BY_RECENT
|
||||
}
|
||||
}
|
||||
|
||||
private fun artistOrder(
|
||||
sort: ArtistSort,
|
||||
descending: Boolean,
|
||||
mode: SortNameMode,
|
||||
): String =
|
||||
when (sort) {
|
||||
ArtistSort.Name -> {
|
||||
if (descending) ARTIST_ORDER_BY_NAME_REVERSED else ARTIST_ORDER_BY_NAME
|
||||
val name = artistNameSortKey(mode)
|
||||
if (descending) "$name DESC, id DESC" else "$name, id"
|
||||
}
|
||||
|
||||
ArtistSort.AlbumCount -> {
|
||||
|
||||
@@ -706,6 +706,30 @@ val MIGRATION_35_36 =
|
||||
}
|
||||
}
|
||||
|
||||
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 ->
|
||||
@@ -782,4 +806,6 @@ val MIGRATIONS: Array<Migration> =
|
||||
MIGRATION_33_34,
|
||||
MIGRATION_34_35,
|
||||
MIGRATION_35_36,
|
||||
MIGRATION_36_37,
|
||||
MIGRATION_37_38,
|
||||
)
|
||||
|
||||
@@ -214,14 +214,34 @@ interface QueueDao {
|
||||
): List<String>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.id IN (:ids)",
|
||||
"SELECT CASE WHEN EXISTS (SELECT 1 FROM songs WHERE sourceId = :sourceId AND albumId = :albumId AND id = :songId) " +
|
||||
"THEN (SELECT COUNT(*) FROM songs ranked CROSS JOIN songs target " +
|
||||
"WHERE ranked.sourceId = :sourceId AND ranked.albumId = :albumId " +
|
||||
"AND target.sourceId = :sourceId AND target.albumId = :albumId AND target.id = :songId " +
|
||||
"AND ((ranked.disc IS NULL AND target.disc IS NOT NULL) OR ranked.disc < target.disc OR " +
|
||||
"(ranked.disc IS target.disc AND ((ranked.track IS NULL AND target.track IS NOT NULL) OR ranked.track < target.track OR " +
|
||||
"(ranked.track IS target.track AND ranked.id < target.id))))) END",
|
||||
)
|
||||
suspend fun songsByIds(
|
||||
suspend fun albumRank(
|
||||
sourceId: Long,
|
||||
ids: List<String>,
|
||||
): List<AlbumSongItem>
|
||||
albumId: String,
|
||||
songId: String,
|
||||
): Long?
|
||||
|
||||
@Query(
|
||||
"SELECT CASE WHEN firstPosition IS NULL THEN NULL ELSE " +
|
||||
"(SELECT COUNT(*) - 1 FROM playlist_songs " +
|
||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId AND position <= firstPosition) END " +
|
||||
"FROM (SELECT MIN(position) AS firstPosition FROM playlist_songs " +
|
||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId AND songId = :songId)",
|
||||
)
|
||||
suspend fun playlistRank(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
songId: String,
|
||||
): Long?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
|
||||
suspend fun genreLength(
|
||||
@@ -243,17 +263,6 @@ interface QueueDao {
|
||||
limit: Int,
|
||||
): List<AlbumSongItem>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM song_genres " +
|
||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
|
||||
)
|
||||
suspend fun genreSongIds(
|
||||
sourceId: Long,
|
||||
genre: String,
|
||||
): List<String>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
|
||||
suspend fun downloadedLength(sourceId: Long): Long
|
||||
|
||||
@@ -272,12 +281,6 @@ interface QueueDao {
|
||||
limit: Int,
|
||||
): List<AlbumSongItem>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
||||
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
|
||||
)
|
||||
suspend fun starredSongIds(sourceId: Long): List<String>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " +
|
||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||
@@ -290,12 +293,4 @@ interface QueueDao {
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
): List<AlbumSongItem>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM song_downloads d " +
|
||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||
)
|
||||
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
|
||||
SongDownload::class,
|
||||
PendingAction::class,
|
||||
],
|
||||
version = 36,
|
||||
version = 38,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(
|
||||
|
||||
@@ -58,9 +58,14 @@ data class SongDownload(
|
||||
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
|
||||
) {
|
||||
val progress: Float?
|
||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
get() = fractionOf(bytes, total)
|
||||
}
|
||||
|
||||
internal fun fractionOf(
|
||||
bytes: Long,
|
||||
total: Long,
|
||||
): Float? = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
|
||||
enum class DownloadList { Album, Playlist, Artist }
|
||||
|
||||
enum class BulkDownloadAction { Download, Cancel, Delete }
|
||||
@@ -99,7 +104,7 @@ data class DownloadedSong(
|
||||
val songSort: String = title,
|
||||
) {
|
||||
val progress: Float?
|
||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
get() = fractionOf(bytes, total)
|
||||
}
|
||||
|
||||
data class DownloadArtwork(
|
||||
|
||||
@@ -59,6 +59,7 @@ data class ArtistRef(
|
||||
indices = [
|
||||
Index("sourceId"),
|
||||
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
|
||||
Index(name = "index_artists_name_plain", value = ["sourceId", "name", "id"]),
|
||||
Index(
|
||||
name = "index_artists_albumCount",
|
||||
value = ["sourceId", "albumCount", "sortName", "id"],
|
||||
@@ -131,7 +132,9 @@ data class ArtistBio(
|
||||
Index("sourceId"),
|
||||
Index("sourceId", "artistId"),
|
||||
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
|
||||
Index(name = "index_albums_name_plain", value = ["sourceId", "name", "id"]),
|
||||
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
|
||||
Index(name = "index_albums_artist_plain", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
||||
Index(
|
||||
name = "index_albums_year",
|
||||
value = ["sourceId", "year", "sortName", "id"],
|
||||
@@ -251,6 +254,8 @@ data class Playlist(
|
||||
val created: Long,
|
||||
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
|
||||
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
|
||||
val owner: String? = null,
|
||||
@ColumnInfo(defaultValue = "0") val shared: Boolean = false,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
|
||||
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class SortNameMode { SortName, Name, NonLatin }
|
||||
|
||||
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
|
||||
|
||||
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
|
||||
@@ -182,6 +184,17 @@ class UserPreferences(
|
||||
|
||||
private val keepScreenOnKey = booleanPreferencesKey("keep_screen_on")
|
||||
|
||||
fun sortNameMode(): Flow<SortNameMode> =
|
||||
store.data.map { prefs ->
|
||||
prefs[sortNameModeKey]?.let { stored -> SortNameMode.entries.firstOrNull { it.name == stored } } ?: SortNameMode.Name
|
||||
}
|
||||
|
||||
suspend fun setSortNameMode(mode: SortNameMode) {
|
||||
store.edit { prefs -> prefs[sortNameModeKey] = mode.name }
|
||||
}
|
||||
|
||||
private val sortNameModeKey = stringPreferencesKey("sort_name_mode")
|
||||
|
||||
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
||||
|
||||
suspend fun setOfflineMode(enabled: Boolean) {
|
||||
|
||||
@@ -7,9 +7,9 @@ import androidx.paging.PagingSource
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.albumsDefaultQuery
|
||||
import com.subtracks.data.db.artistsDefaultQuery
|
||||
import com.subtracks.data.db.playlistsDefaultQuery
|
||||
import com.subtracks.data.db.albumsPageQuery
|
||||
import com.subtracks.data.db.artistsPageQuery
|
||||
import com.subtracks.data.db.playlistsPageQuery
|
||||
import com.subtracks.data.db.rediscoverAlbumsQuery
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
@@ -24,27 +24,32 @@ import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LibraryRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val serverActions: ServerActionSink,
|
||||
private val userPreferences: UserPreferences,
|
||||
private val showMessage: (UiMessage) -> Unit = {},
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) {
|
||||
@@ -111,12 +116,18 @@ class LibraryRepository(
|
||||
fun searchAlbums(
|
||||
sourceId: Long,
|
||||
query: String,
|
||||
): Flow<List<Album>> = db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||
): Flow<List<Album>> =
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||
}
|
||||
|
||||
fun searchArtists(
|
||||
sourceId: Long,
|
||||
query: String,
|
||||
): Flow<List<Artist>> = db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||
): Flow<List<Artist>> =
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||
}
|
||||
|
||||
fun searchPlaylists(
|
||||
sourceId: Long,
|
||||
@@ -126,7 +137,10 @@ class LibraryRepository(
|
||||
fun searchSongs(
|
||||
sourceId: Long,
|
||||
query: String,
|
||||
): Flow<List<AlbumSongItem>> = db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||
): Flow<List<AlbumSongItem>> =
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||
}
|
||||
|
||||
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
||||
|
||||
@@ -178,69 +192,20 @@ class LibraryRepository(
|
||||
search: String,
|
||||
downloaded: Boolean,
|
||||
): Flow<PagingData<Album>> =
|
||||
pager(40, enablePlaceholders = true) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
AlbumSort.Name -> {
|
||||
if (descending) {
|
||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Artist -> {
|
||||
if (descending) {
|
||||
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Year -> {
|
||||
if (descending) {
|
||||
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Added -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
pager(40, enablePlaceholders = true) {
|
||||
val dao = db.libraryDao()
|
||||
val query =
|
||||
albumsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starred.ordinal,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = downloaded,
|
||||
sortNameMode = mode,
|
||||
)
|
||||
if (downloaded) dao.downloadedAlbumsPaging(query) else dao.albumsPaging(query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,53 +217,20 @@ class LibraryRepository(
|
||||
search: String,
|
||||
downloaded: Boolean,
|
||||
): Flow<PagingData<Artist>> =
|
||||
pager(60) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
ArtistSort.Name -> {
|
||||
if (descending) {
|
||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.AlbumCount -> {
|
||||
if (descending) {
|
||||
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
pager(60) {
|
||||
val dao = db.libraryDao()
|
||||
val query =
|
||||
artistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starred.ordinal,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = downloaded,
|
||||
sortNameMode = mode,
|
||||
)
|
||||
if (downloaded) dao.downloadedArtistsPaging(query) else dao.artistsPaging(query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,36 +243,15 @@ class LibraryRepository(
|
||||
): Flow<PagingData<Playlist>> =
|
||||
pager(40) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.playlistsPaging(playlistsDefaultQuery(sourceId, search, sort, descending))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
PlaylistSort.Name -> {
|
||||
if (descending) {
|
||||
dao.playlistsByNameReversed(sourceId, search, downloadedFilter)
|
||||
} else {
|
||||
dao.playlistsByName(sourceId, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
PlaylistSort.Added -> {
|
||||
if (descending) {
|
||||
dao.playlistsByAddedReversed(sourceId, search, downloadedFilter)
|
||||
} else {
|
||||
dao.playlistsByAdded(sourceId, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
PlaylistSort.Updated -> {
|
||||
if (descending) {
|
||||
dao.playlistsByUpdatedReversed(sourceId, search, downloadedFilter)
|
||||
} else {
|
||||
dao.playlistsByUpdated(sourceId, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val query =
|
||||
playlistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = downloaded,
|
||||
)
|
||||
if (downloaded) dao.downloadedPlaylistsPaging(query) else dao.playlistsPaging(query)
|
||||
}
|
||||
|
||||
fun album(
|
||||
@@ -424,6 +335,11 @@ class LibraryRepository(
|
||||
}
|
||||
}
|
||||
|
||||
fun artistCoverArts(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<String>> = db.libraryDao().artistCoverArts(sourceId, artistId)
|
||||
|
||||
fun albumDiscs(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
@@ -444,10 +360,7 @@ class LibraryRepository(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
songId: String,
|
||||
): Long {
|
||||
val index = db.queueDao().albumSongIds(sourceId, albumId).indexOf(songId)
|
||||
return if (index < 0) 0L else index.toLong()
|
||||
}
|
||||
): Long = db.queueDao().albumRank(sourceId, albumId, songId) ?: 0L
|
||||
|
||||
fun playlist(
|
||||
sourceId: Long,
|
||||
|
||||
@@ -209,6 +209,202 @@ data class Reanchored(
|
||||
val changed: Boolean,
|
||||
)
|
||||
|
||||
private interface QueueKindResolver {
|
||||
fun supportsKeyset(key: OrderKey): Boolean = false
|
||||
|
||||
suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
): Long
|
||||
|
||||
suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
): List<SongItem>
|
||||
|
||||
suspend fun rowsFrom(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem>? = null
|
||||
|
||||
suspend fun rowsBefore(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem>? = null
|
||||
|
||||
suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
): Long? = null
|
||||
}
|
||||
|
||||
private val queueKindResolvers =
|
||||
mapOf<QueueKind, QueueKindResolver>(
|
||||
QueueKind.Song to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.songLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.song(entry.sourceId, entry.refId, offset, limit)
|
||||
|
||||
override suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
) = if (entry.refId == songId && length(dao, entry) > 0) 0L else null
|
||||
},
|
||||
QueueKind.Playlist to
|
||||
object : QueueKindResolver {
|
||||
override fun supportsKeyset(key: OrderKey) = key.position != null
|
||||
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.playlistLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
|
||||
override suspend fun rowsFrom(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
|
||||
override suspend fun rowsBefore(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
|
||||
override suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
) = dao.playlistRank(entry.sourceId, entry.refId, songId)
|
||||
},
|
||||
QueueKind.Album to
|
||||
object : QueueKindResolver {
|
||||
override fun supportsKeyset(key: OrderKey) = key.disc != null && key.track != null
|
||||
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.albumLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
|
||||
override suspend fun rowsFrom(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
|
||||
override suspend fun rowsBefore(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
|
||||
override suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
) = dao.albumRank(entry.sourceId, entry.refId, songId)
|
||||
},
|
||||
QueueKind.Artist to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.artistLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
},
|
||||
QueueKind.Genre to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.genreLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
},
|
||||
QueueKind.Downloaded to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.downloadedLength(entry.sourceId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.downloadedSongs(entry.sourceId, offset, limit)
|
||||
},
|
||||
QueueKind.Starred to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.starredLength(entry.sourceId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.starredSongs(entry.sourceId, offset, limit)
|
||||
},
|
||||
)
|
||||
|
||||
private fun resolverFor(kind: QueueKind): QueueKindResolver = queueKindResolvers.getValue(kind)
|
||||
|
||||
class QueueRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
) {
|
||||
@@ -216,12 +412,6 @@ class QueueRepository(
|
||||
|
||||
private val queueVersion = AtomicLong()
|
||||
|
||||
@Volatile
|
||||
private var cachedFlatIds: List<String>? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedFlatVersion = -1L
|
||||
|
||||
@Volatile
|
||||
private var shuffleRemoved: ShuffleState? = null
|
||||
|
||||
@@ -235,6 +425,9 @@ class QueueRepository(
|
||||
internal var offsetSeeks = 0L
|
||||
private set
|
||||
|
||||
internal var resolvedRowCount = 0L
|
||||
private set
|
||||
|
||||
private val cursorMutex = Mutex()
|
||||
|
||||
fun playlistEntry(
|
||||
@@ -388,7 +581,6 @@ class QueueRepository(
|
||||
}
|
||||
|
||||
fun invalidateLibraryCache() {
|
||||
cachedFlatIds = null
|
||||
lengthCache.clear()
|
||||
keyCache.clear()
|
||||
shuffleRemoved = null
|
||||
@@ -409,8 +601,8 @@ class QueueRepository(
|
||||
snapshot: QueueSnapshot,
|
||||
songId: String,
|
||||
): Long? {
|
||||
val upIndex = idsFor(snapshot.upNext).indexOf(songId)
|
||||
if (upIndex >= 0) return snapshot.anchorPlay + 1 + upIndex
|
||||
val upIndex = flatIndexOf(snapshot.upNext, songId)
|
||||
if (upIndex != null) return snapshot.anchorPlay + 1 + upIndex
|
||||
return combinedContextIndexOf(snapshot, songId)
|
||||
}
|
||||
|
||||
@@ -418,9 +610,8 @@ class QueueRepository(
|
||||
snapshot: QueueSnapshot,
|
||||
songId: String,
|
||||
): Long? {
|
||||
val flat = flatIds(snapshot).indexOf(songId)
|
||||
if (flat < 0) return null
|
||||
return snapshot.playContext(flat.toLong())?.let { snapshot.combined(it) }
|
||||
val flat = flatIndexOf(snapshot.entries, songId) ?: return null
|
||||
return snapshot.playContext(flat)?.let { snapshot.combined(it) }
|
||||
}
|
||||
|
||||
suspend fun range(
|
||||
@@ -633,7 +824,6 @@ class QueueRepository(
|
||||
}
|
||||
}
|
||||
queueVersion.incrementAndGet()
|
||||
cachedFlatIds = null
|
||||
}
|
||||
|
||||
private suspend fun writeUpNext(entries: List<QueueEntry>) {
|
||||
@@ -650,34 +840,40 @@ class QueueRepository(
|
||||
queueVersion.incrementAndGet()
|
||||
}
|
||||
|
||||
private suspend fun flatIds(snapshot: QueueSnapshot): List<String> {
|
||||
val cached = cachedFlatIds
|
||||
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached
|
||||
val ids = idsFor(snapshot.entries)
|
||||
cachedFlatIds = ids
|
||||
cachedFlatVersion = snapshot.version
|
||||
return ids
|
||||
private suspend fun flatIndexOf(
|
||||
entries: List<ResolvedQueueEntry>,
|
||||
songId: String,
|
||||
): Long? {
|
||||
var start = 0L
|
||||
for (resolved in entries) {
|
||||
val entry = resolved.entry
|
||||
val rank = resolverFor(entry.kind).rank(dao, entry, songId)
|
||||
if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) {
|
||||
return start + rank - entry.offset
|
||||
}
|
||||
val fallback = fallbackRank(entry, resolved.length, songId)
|
||||
if (fallback != null) return start + fallback - entry.offset
|
||||
start += resolved.length
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun idsFor(resolved: List<ResolvedQueueEntry>): List<String> {
|
||||
val ids = ArrayList<String>()
|
||||
for (entry in resolved) {
|
||||
val ref = entry.entry
|
||||
val entryIds =
|
||||
when (ref.kind) {
|
||||
QueueKind.Song -> listOf(ref.refId)
|
||||
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Genre -> dao.genreSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Downloaded -> dao.downloadedSongIds(ref.sourceId)
|
||||
QueueKind.Starred -> dao.starredSongIds(ref.sourceId)
|
||||
}
|
||||
val from = ref.offset.toInt().coerceIn(0, entryIds.size)
|
||||
val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size)
|
||||
ids.addAll(entryIds.subList(from, to))
|
||||
private suspend fun fallbackRank(
|
||||
entry: QueueEntry,
|
||||
length: Long,
|
||||
songId: String,
|
||||
): Long? {
|
||||
var offset = entry.offset
|
||||
val end = entry.offset + length
|
||||
while (offset < end) {
|
||||
val limit = minOf(QUEUE_CHUNK.toLong(), end - offset).toInt()
|
||||
val batch = rows(entry, offset, limit)
|
||||
val index = batch.indexOfFirst { it.song.id == songId }
|
||||
if (index >= 0) return offset + index
|
||||
if (batch.size < limit) return null
|
||||
offset += batch.size
|
||||
}
|
||||
return ids
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun contextPlayRange(
|
||||
@@ -688,21 +884,11 @@ class QueueRepository(
|
||||
if (first > last) return emptyList()
|
||||
val items = mutableListOf<QueueWindowItem>()
|
||||
if (snapshot.shuffled) {
|
||||
val sourceId =
|
||||
snapshot.entries
|
||||
.firstOrNull()
|
||||
?.entry
|
||||
?.sourceId ?: return emptyList()
|
||||
val ids = flatIds(snapshot)
|
||||
val requested = mutableListOf<Pair<Long, String>>()
|
||||
for (play in first..last) {
|
||||
val flat = snapshot.flatContext(play) ?: continue
|
||||
val id = ids.getOrNull(flat.toInt()) ?: continue
|
||||
requested += play to id
|
||||
val located = snapshot.locateContext(play) ?: continue
|
||||
val item = rows(located.first, located.first.offset + located.second, 1).firstOrNull() ?: continue
|
||||
items += QueueWindowItem(snapshot.combined(play), item, playlistPosition = item.playlistPosition)
|
||||
}
|
||||
if (requested.isEmpty()) return emptyList()
|
||||
val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id }
|
||||
requested.forEach { (play, id) -> byId[id]?.let { items += QueueWindowItem(snapshot.combined(play), it) } }
|
||||
return items
|
||||
}
|
||||
var start = 0L
|
||||
@@ -757,27 +943,20 @@ class QueueRepository(
|
||||
): List<SongItem> {
|
||||
if (entry.kind == QueueKind.Song) {
|
||||
offsetSeeks++
|
||||
return dao.song(entry.sourceId, entry.refId, offset, limit)
|
||||
return resolverFor(entry.kind).rows(dao, entry, offset, limit).also { resolvedRowCount += it.size }
|
||||
}
|
||||
val ref = QueueRef(entry.kind, entry.sourceId, entry.refId)
|
||||
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
|
||||
if (anchored != null) {
|
||||
keysetSeeks++
|
||||
if (anchored.isNotEmpty()) remember(ref, offset, anchored)
|
||||
resolvedRowCount += anchored.size
|
||||
return anchored
|
||||
}
|
||||
offsetSeeks++
|
||||
val fresh =
|
||||
when (entry.kind) {
|
||||
QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Artist -> dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Genre -> dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Downloaded -> dao.downloadedSongs(entry.sourceId, offset, limit)
|
||||
QueueKind.Starred -> dao.starredSongs(entry.sourceId, offset, limit)
|
||||
QueueKind.Song -> emptyList()
|
||||
}
|
||||
val fresh = resolverFor(entry.kind).rows(dao, entry, offset, limit)
|
||||
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
|
||||
resolvedRowCount += fresh.size
|
||||
return fresh
|
||||
}
|
||||
|
||||
@@ -810,7 +989,7 @@ class QueueRepository(
|
||||
limit: Int,
|
||||
cursor: KeyCursor,
|
||||
): List<SongItem>? {
|
||||
if (!cursor.key.seekable(entry.kind)) return null
|
||||
if (!resolverFor(entry.kind).supportsKeyset(cursor.key)) return null
|
||||
val from = cursor.ordinal
|
||||
// A backward seek from a far cursor is not cheaper than the offset query: the album
|
||||
// predicate is not sargable (it has to include NULL disc/track), so it scans the partition
|
||||
@@ -836,43 +1015,14 @@ class QueueRepository(
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem> =
|
||||
when (entry.kind) {
|
||||
QueueKind.Playlist -> dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
QueueKind.Album -> dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
QueueKind.Artist -> emptyList()
|
||||
QueueKind.Genre -> emptyList()
|
||||
QueueKind.Downloaded -> emptyList()
|
||||
QueueKind.Starred -> emptyList()
|
||||
QueueKind.Song -> emptyList()
|
||||
}
|
||||
): List<SongItem> = resolverFor(entry.kind).rowsFrom(dao, entry, key, skip, limit) ?: emptyList()
|
||||
|
||||
private suspend fun rowsBefore(
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem> =
|
||||
when (entry.kind) {
|
||||
QueueKind.Playlist -> dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
QueueKind.Album -> dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
QueueKind.Artist -> emptyList()
|
||||
QueueKind.Genre -> emptyList()
|
||||
QueueKind.Downloaded -> emptyList()
|
||||
QueueKind.Starred -> emptyList()
|
||||
QueueKind.Song -> emptyList()
|
||||
}
|
||||
|
||||
private fun OrderKey.seekable(kind: QueueKind): Boolean =
|
||||
when (kind) {
|
||||
QueueKind.Playlist -> position != null
|
||||
QueueKind.Album -> disc != null && track != null
|
||||
QueueKind.Artist -> false
|
||||
QueueKind.Genre -> false
|
||||
QueueKind.Downloaded -> false
|
||||
QueueKind.Starred -> false
|
||||
QueueKind.Song -> false
|
||||
}
|
||||
): List<SongItem> = resolverFor(entry.kind).rowsBefore(dao, entry, key, skip, limit) ?: emptyList()
|
||||
|
||||
private suspend fun lengthOf(
|
||||
entry: QueueEntry,
|
||||
@@ -897,36 +1047,7 @@ class QueueRepository(
|
||||
)
|
||||
|
||||
private suspend fun QueueEntry.resolvedLength(): Long {
|
||||
val total =
|
||||
when (kind) {
|
||||
QueueKind.Song -> {
|
||||
dao.songLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Playlist -> {
|
||||
dao.playlistLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Album -> {
|
||||
dao.albumLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Artist -> {
|
||||
dao.artistLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Genre -> {
|
||||
dao.genreLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Downloaded -> {
|
||||
dao.downloadedLength(sourceId)
|
||||
}
|
||||
|
||||
QueueKind.Starred -> {
|
||||
dao.starredLength(sourceId)
|
||||
}
|
||||
}
|
||||
val total = resolverFor(kind).length(dao, this)
|
||||
val available = (total - offset).coerceAtLeast(0)
|
||||
return count?.coerceAtMost(available) ?: available
|
||||
}
|
||||
|
||||
@@ -129,6 +129,8 @@ object SubsonicXml {
|
||||
created = attrs.dateAttr("created") ?: 0L,
|
||||
changed = attrs.dateAttr("changed") ?: 0L,
|
||||
duration = attrs.longAttr("duration") ?: 0L,
|
||||
owner = attrs.attr("owner").ifEmpty { null },
|
||||
shared = attrs.boolAttr("public"),
|
||||
)
|
||||
|
||||
internal fun playlistSong(
|
||||
@@ -365,6 +367,8 @@ internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
|
||||
|
||||
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
||||
|
||||
internal fun Attributes.boolAttr(name: String): Boolean = attr(name).let { it == "1" || it.equals("true", ignoreCase = true) }
|
||||
|
||||
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() }
|
||||
|
||||
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
||||
|
||||
@@ -61,7 +61,7 @@ class SyncService(
|
||||
source.artists().collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
changed =
|
||||
write("artists", artistColumns, primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.values() } ||
|
||||
write("artists", primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.columns() } ||
|
||||
changed
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
artistsSynced += batch.size
|
||||
@@ -137,7 +137,7 @@ class SyncService(
|
||||
connection.immediateTransaction {
|
||||
val before = connection.totalChanges()
|
||||
val resolved = connection.resolveSongSortNames(batch)
|
||||
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
|
||||
connection.upsertChanged("songs", primaryKey, resolved) { it.columns() }
|
||||
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||
batch.forEach { song ->
|
||||
statement.bind(1, song.sourceId)
|
||||
@@ -148,7 +148,7 @@ class SyncService(
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
|
||||
connection.upsertChanged("song_genres", songGenreKey, genres) { it.columns() }
|
||||
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||
batch.forEach { song ->
|
||||
statement.bind(1, song.sourceId)
|
||||
@@ -159,7 +159,7 @@ class SyncService(
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
|
||||
connection.upsertChanged("song_artists", songArtistKey, artists) { it.columns() }
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed) connection.markCreditsStale(batch.first().sourceId)
|
||||
}
|
||||
@@ -256,7 +256,7 @@ class SyncService(
|
||||
val seen = HashedIds()
|
||||
source.playlists().collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
||||
write("playlists", primaryKey, batch) { it.columns() }
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
playlistsSynced += batch.size
|
||||
}
|
||||
@@ -269,7 +269,7 @@ class SyncService(
|
||||
val counts = HashMap<String, Long>()
|
||||
source.playlistSongs(playlistIds).collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
||||
write("playlist_songs", playlistSongKey, batch) { it.columns() }
|
||||
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
||||
playlistSongsSynced += batch.size
|
||||
}
|
||||
@@ -280,17 +280,16 @@ class SyncService(
|
||||
// One commit per batch, so Room notifies paged observers once per batch rather than per row.
|
||||
private suspend fun <T> write(
|
||||
table: String,
|
||||
columns: List<String>,
|
||||
key: List<String>,
|
||||
rows: List<T>,
|
||||
preserved: Set<String> = emptySet(),
|
||||
markStale: Boolean = false,
|
||||
values: (T) -> List<Any?>,
|
||||
columns: (T) -> List<Pair<String, Any?>>,
|
||||
): Boolean =
|
||||
db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
val before = connection.totalChanges()
|
||||
connection.upsertChanged(table, columns, key, rows, preserved, values)
|
||||
connection.upsertChanged(table, key, rows, preserved, columns)
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed && markStale) connection.markCreditsStale(source.id)
|
||||
}
|
||||
@@ -309,8 +308,8 @@ class SyncService(
|
||||
connection.immediateTransaction {
|
||||
val before = connection.totalChanges()
|
||||
val resolved = connection.resolveAlbumSortNames(albums)
|
||||
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
|
||||
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
||||
connection.upsertChanged("albums", primaryKey, resolved) { it.columns() }
|
||||
connection.upsertChanged("discs", discKey, discs) { it.columns() }
|
||||
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
|
||||
albums.forEach { album ->
|
||||
statement.bind(1, album.sourceId)
|
||||
@@ -321,7 +320,7 @@ class SyncService(
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
|
||||
connection.upsertChanged("album_artists", albumArtistKey, artists) { it.columns() }
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed) connection.markCreditsStale(albums.first().sourceId)
|
||||
}
|
||||
@@ -438,57 +437,8 @@ private fun discHash(
|
||||
|
||||
private val primaryKey = listOf("sourceId", "id")
|
||||
|
||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "artistImageUrl", "sortName")
|
||||
|
||||
private val albumColumns =
|
||||
listOf(
|
||||
"sourceId",
|
||||
"id",
|
||||
"artistId",
|
||||
"name",
|
||||
"albumArtist",
|
||||
"created",
|
||||
"coverArt",
|
||||
"genre",
|
||||
"year",
|
||||
"starred",
|
||||
"songCount",
|
||||
"artists",
|
||||
"sortName",
|
||||
"albumArtistSort",
|
||||
)
|
||||
|
||||
private val discKey = listOf("sourceId", "albumId", "disc")
|
||||
|
||||
private val discColumns = listOf("sourceId", "albumId", "disc", "title")
|
||||
|
||||
private val songColumns =
|
||||
listOf(
|
||||
"sourceId",
|
||||
"id",
|
||||
"albumId",
|
||||
"artistId",
|
||||
"title",
|
||||
"album",
|
||||
"artist",
|
||||
"duration",
|
||||
"track",
|
||||
"disc",
|
||||
"starred",
|
||||
"created",
|
||||
"playCount",
|
||||
"played",
|
||||
"trackGain",
|
||||
"albumGain",
|
||||
"trackPeak",
|
||||
"albumPeak",
|
||||
"artists",
|
||||
"sortName",
|
||||
"artistSort",
|
||||
)
|
||||
|
||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
||||
|
||||
private fun Song.genreNames(): List<String> =
|
||||
genres
|
||||
.flatMap { it.genreParts() }
|
||||
@@ -499,71 +449,93 @@ private fun Song.genreNames(): List<String> =
|
||||
// Navidrome's default genre tag split (resources/mappings.yaml): semicolon, slash, comma.
|
||||
private fun String.genreParts(): List<String> = split(';', '/', ',').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
|
||||
private val songGenreColumns = listOf("sourceId", "songId", "position", "genre")
|
||||
|
||||
private val songGenreKey = listOf("sourceId", "songId", "position")
|
||||
|
||||
private val albumArtistColumns = listOf("sourceId", "albumId", "position", "artistId", "name")
|
||||
|
||||
private val albumArtistKey = listOf("sourceId", "albumId", "position")
|
||||
|
||||
private val songArtistColumns = listOf("sourceId", "songId", "position", "artistId", "name")
|
||||
|
||||
private val songArtistKey = listOf("sourceId", "songId", "position")
|
||||
|
||||
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
||||
|
||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||
|
||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, artistImageUrl, sortName)
|
||||
|
||||
private fun Album.values() =
|
||||
listOf<Any?>(
|
||||
sourceId,
|
||||
id,
|
||||
artistId,
|
||||
name,
|
||||
albumArtist,
|
||||
created,
|
||||
coverArt,
|
||||
genre,
|
||||
year,
|
||||
starred,
|
||||
songCount,
|
||||
ArtistRefsCodec.encode(artistRefs),
|
||||
sortName,
|
||||
albumArtistSort,
|
||||
private fun Artist.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"id" to id,
|
||||
"name" to name,
|
||||
"albumCount" to albumCount,
|
||||
"starred" to starred,
|
||||
"coverArt" to coverArt,
|
||||
"artistImageUrl" to artistImageUrl,
|
||||
"sortName" to sortName,
|
||||
)
|
||||
|
||||
private fun AlbumArtistRef.values() = listOf<Any?>(sourceId, albumId, position, artistId, name)
|
||||
private fun Album.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"id" to id,
|
||||
"artistId" to artistId,
|
||||
"name" to name,
|
||||
"albumArtist" to albumArtist,
|
||||
"created" to created,
|
||||
"coverArt" to coverArt,
|
||||
"genre" to genre,
|
||||
"year" to year,
|
||||
"starred" to starred,
|
||||
"songCount" to songCount,
|
||||
"artists" to ArtistRefsCodec.encode(artistRefs),
|
||||
"sortName" to sortName,
|
||||
"albumArtistSort" to albumArtistSort,
|
||||
)
|
||||
|
||||
private fun SongArtistRef.values() = listOf<Any?>(sourceId, songId, position, artistId, name)
|
||||
private fun AlbumArtistRef.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"albumId" to albumId,
|
||||
"position" to position,
|
||||
"artistId" to artistId,
|
||||
"name" to name,
|
||||
)
|
||||
|
||||
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
||||
private fun SongArtistRef.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"songId" to songId,
|
||||
"position" to position,
|
||||
"artistId" to artistId,
|
||||
"name" to name,
|
||||
)
|
||||
|
||||
private fun Song.values() =
|
||||
listOf<Any?>(
|
||||
sourceId,
|
||||
id,
|
||||
albumId,
|
||||
artistId,
|
||||
title,
|
||||
album,
|
||||
artist,
|
||||
duration,
|
||||
track,
|
||||
disc,
|
||||
starred,
|
||||
created,
|
||||
playCount,
|
||||
played,
|
||||
trackGain,
|
||||
albumGain,
|
||||
trackPeak,
|
||||
albumPeak,
|
||||
ArtistRefsCodec.encode(artistRefs),
|
||||
sortName,
|
||||
artistSort,
|
||||
private fun Disc.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"albumId" to albumId,
|
||||
"disc" to disc,
|
||||
"title" to title,
|
||||
)
|
||||
|
||||
private fun Song.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"id" to id,
|
||||
"albumId" to albumId,
|
||||
"artistId" to artistId,
|
||||
"title" to title,
|
||||
"album" to album,
|
||||
"artist" to artist,
|
||||
"duration" to duration,
|
||||
"track" to track,
|
||||
"disc" to disc,
|
||||
"starred" to starred,
|
||||
"created" to created,
|
||||
"playCount" to playCount,
|
||||
"played" to played,
|
||||
"trackGain" to trackGain,
|
||||
"albumGain" to albumGain,
|
||||
"trackPeak" to trackPeak,
|
||||
"albumPeak" to albumPeak,
|
||||
"artists" to ArtistRefsCodec.encode(artistRefs),
|
||||
"sortName" to sortName,
|
||||
"artistSort" to artistSort,
|
||||
)
|
||||
|
||||
private fun List<ArtistRef>.positions(): List<Pair<Long, ArtistRef>> =
|
||||
@@ -575,32 +547,56 @@ private fun Album.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.po
|
||||
|
||||
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
||||
|
||||
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
||||
private fun SongGenre.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"songId" to songId,
|
||||
"position" to position,
|
||||
"genre" to genre,
|
||||
)
|
||||
|
||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
||||
private fun Playlist.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"id" to id,
|
||||
"name" to name,
|
||||
"comment" to comment,
|
||||
"coverArt" to coverArt,
|
||||
"songCount" to songCount,
|
||||
"created" to created,
|
||||
"changed" to changed,
|
||||
"duration" to duration,
|
||||
"owner" to owner,
|
||||
"shared" to shared,
|
||||
)
|
||||
|
||||
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
|
||||
private fun PlaylistSong.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"playlistId" to playlistId,
|
||||
"songId" to songId,
|
||||
"position" to position,
|
||||
)
|
||||
|
||||
private suspend fun <T> PooledConnection.upsertChanged(
|
||||
table: String,
|
||||
columns: List<String>,
|
||||
key: List<String>,
|
||||
rows: List<T>,
|
||||
preserved: Set<String> = emptySet(),
|
||||
values: (T) -> List<Any?>,
|
||||
columns: (T) -> List<Pair<String, Any?>>,
|
||||
) {
|
||||
if (rows.isEmpty()) return
|
||||
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
|
||||
val updates = columns.filterNot { it in key || it in preserved }
|
||||
val columnNames = columns(rows.first()).map { it.first }
|
||||
val updates = columnNames.filterNot { it in key || it in preserved }
|
||||
val sql =
|
||||
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
|
||||
"INSERT INTO $table (${columnNames.joinToString()}) VALUES (${columnNames.joinToString { "?" }}) " +
|
||||
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
||||
updates.joinToString { "$it = excluded.$it" } +
|
||||
" WHERE " +
|
||||
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" }
|
||||
usePrepared(sql) { statement ->
|
||||
rows.forEach { row ->
|
||||
values(row).forEachIndexed { index, value -> statement.bind(index + 1, value) }
|
||||
columns(row).forEachIndexed { index, (_, value) -> statement.bind(index + 1, value) }
|
||||
statement.step()
|
||||
statement.reset()
|
||||
statement.clearBindings()
|
||||
|
||||
@@ -96,7 +96,7 @@ fun appModule(
|
||||
scope = get(),
|
||||
)
|
||||
}
|
||||
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
|
||||
single { LibraryRepository(get(), get(), get(), get(), toast, scope = get(named("io"))) }
|
||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
||||
single {
|
||||
|
||||
@@ -12,6 +12,8 @@ import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.ArtistRefsCodec
|
||||
import com.subtracks.data.model.AudioEncoding
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
@@ -229,6 +231,7 @@ class Media3PlayerHandle(
|
||||
.orEmpty(),
|
||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||
artists = mediaItem.artistRefs(),
|
||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||
durationMs =
|
||||
mediaItem.mediaMetadata.extras
|
||||
@@ -238,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_ARTISTS = "artists"
|
||||
private const val EXTRA_DURATION_MS = "durationMs"
|
||||
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
||||
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
||||
@@ -286,6 +296,7 @@ internal fun queueMediaItem(
|
||||
.setExtras(
|
||||
Bundle().apply {
|
||||
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||
putString(EXTRA_ARTISTS, ArtistRefsCodec.encode(item.artists))
|
||||
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
||||
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
||||
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.AudioEncoding
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
@@ -36,6 +37,7 @@ data class QueueItem(
|
||||
val durationMs: Long? = null,
|
||||
val replayGain: ReplayGain = ReplayGain(),
|
||||
val albumContext: Boolean = false,
|
||||
val artists: List<ArtistRef> = emptyList(),
|
||||
)
|
||||
|
||||
data class QueueContext(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.SongItem
|
||||
|
||||
fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
||||
@@ -7,6 +8,7 @@ fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
||||
id = song.id,
|
||||
title = song.title,
|
||||
artist = song.artist,
|
||||
artists = song.artistRefs,
|
||||
album = song.album,
|
||||
coverArtId = coverArt,
|
||||
durationMs = song.duration?.let { it * 1000 },
|
||||
|
||||
@@ -61,14 +61,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.AlbumInfoDialog
|
||||
import com.subtracks.ui.components.ArtistChooserSheet
|
||||
import com.subtracks.ui.components.ArtistInfoDialog
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.ItemContextMenu
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PlaylistInfoDialog
|
||||
import com.subtracks.ui.components.SongInfoDialog
|
||||
import com.subtracks.ui.components.statusBarScrim
|
||||
import com.subtracks.ui.downloads.DownloadsRoute
|
||||
@@ -230,7 +236,8 @@ private fun MainNavigation() {
|
||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val contextMenuHost = remember { ContextMenuHost() }
|
||||
var infoSong by remember { mutableStateOf<Song?>(null) }
|
||||
var infoTarget by remember { mutableStateOf<MenuTarget?>(null) }
|
||||
var artistChooser by remember { mutableStateOf<List<ArtistRef>?>(null) }
|
||||
|
||||
val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null)
|
||||
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
@@ -312,7 +319,8 @@ private fun MainNavigation() {
|
||||
nowPlayingLauncher.openNowPlaying.collect {
|
||||
focusManager.clearFocus()
|
||||
contextMenuHost.dismissTransients()
|
||||
infoSong = null
|
||||
infoTarget = null
|
||||
artistChooser = null
|
||||
showingQueue = false
|
||||
if (navController.currentDestination?.route in DETAIL_ROUTES) {
|
||||
navController.popBackStack(Routes.LIBRARY, inclusive = false)
|
||||
@@ -773,23 +781,69 @@ private fun MainNavigation() {
|
||||
target = target,
|
||||
actions = contextMenuHost.actions,
|
||||
onDismiss = { contextMenuHost.dismiss() },
|
||||
onInfo = { infoSong = it },
|
||||
onInfo = { infoTarget = it },
|
||||
onChooseArtists = { artistChooser = it },
|
||||
)
|
||||
}
|
||||
|
||||
infoSong?.let { song ->
|
||||
val genres by
|
||||
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
SongInfoDialog(
|
||||
song = song,
|
||||
download = downloads[song.id],
|
||||
localFile = downloadRepository.localFile(song.id),
|
||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||
onDismiss = { infoSong = null },
|
||||
genres = genres,
|
||||
artistChooser?.let { artists ->
|
||||
ArtistChooserSheet(
|
||||
artists = artists,
|
||||
onArtistClick = { artist ->
|
||||
artistChooser = null
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(artist.id),
|
||||
)
|
||||
},
|
||||
onDismiss = { artistChooser = null },
|
||||
)
|
||||
}
|
||||
|
||||
when (val target = infoTarget) {
|
||||
is MenuTarget.Song -> {
|
||||
val song = target.song
|
||||
val genres by
|
||||
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
SongInfoDialog(
|
||||
song = song,
|
||||
download = downloads[song.id],
|
||||
localFile = downloadRepository.localFile(song.id),
|
||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||
onDismiss = { infoTarget = null },
|
||||
genres = genres,
|
||||
)
|
||||
}
|
||||
|
||||
is MenuTarget.Album -> {
|
||||
AlbumInfoDialog(
|
||||
album = target.album,
|
||||
download = target.download,
|
||||
onDismiss = { infoTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
is MenuTarget.Artist -> {
|
||||
ArtistInfoDialog(
|
||||
artist = target.artist,
|
||||
onDismiss = { infoTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
is MenuTarget.Playlist -> {
|
||||
PlaylistInfoDialog(
|
||||
playlist = target.playlist,
|
||||
onDismiss = { infoTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,21 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PendingDownloadDelete(
|
||||
val name: String,
|
||||
@@ -18,6 +29,70 @@ data class PendingDownloadDelete(
|
||||
val bytes: Long,
|
||||
)
|
||||
|
||||
class PendingDownloadDeleteController internal constructor(
|
||||
private val scope: CoroutineScope,
|
||||
private val downloadedBytes: State<suspend (DownloadList, String) -> Long>,
|
||||
) {
|
||||
var pending by mutableStateOf<PendingDownloadDelete?>(null)
|
||||
private set
|
||||
|
||||
fun onAction(
|
||||
target: MenuTarget,
|
||||
action: BulkDownloadAction,
|
||||
onAction: (DownloadList, String, BulkDownloadAction) -> Unit,
|
||||
) {
|
||||
val (list, refId) = target.bulkRef() ?: return
|
||||
onAction(target.title, list, refId, action, onAction)
|
||||
}
|
||||
|
||||
fun onAction(
|
||||
name: String,
|
||||
list: DownloadList,
|
||||
refId: String,
|
||||
action: BulkDownloadAction,
|
||||
onAction: (DownloadList, String, BulkDownloadAction) -> Unit,
|
||||
) {
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch {
|
||||
pending = PendingDownloadDelete(name, list, refId, downloadedBytes.value(list, refId))
|
||||
}
|
||||
} else {
|
||||
onAction(list, refId, action)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberPendingDownloadDelete(
|
||||
dismissals: Flow<Unit>,
|
||||
downloadedBytes: suspend (DownloadList, String) -> Long,
|
||||
): PendingDownloadDeleteController {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentDownloadedBytes = rememberUpdatedState(downloadedBytes)
|
||||
val controller = remember(scope) { PendingDownloadDeleteController(scope, currentDownloadedBytes) }
|
||||
DismissOnRequest(dismissals, controller::dismiss)
|
||||
return controller
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PendingDownloadDeleteConfirmation(
|
||||
controller: PendingDownloadDeleteController,
|
||||
onConfirm: (PendingDownloadDelete) -> Unit,
|
||||
) {
|
||||
controller.pending?.let { pending ->
|
||||
DeleteDownloadsDialog(
|
||||
name = pending.name,
|
||||
bytes = pending.bytes,
|
||||
onConfirm = { onConfirm(pending) },
|
||||
onDismiss = controller::dismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteDownloadsDialog(
|
||||
name: String,
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -47,12 +48,17 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -98,12 +104,13 @@ fun HeroDetailScaffold(
|
||||
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
|
||||
content: LazyListScope.(rowModifier: Modifier) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentReady: Boolean = true,
|
||||
) {
|
||||
val effectiveArtwork = artwork ?: baseArtworkColors()
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||
ArtworkTheme(effectiveArtwork) {
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
val listState = rememberLazyListState()
|
||||
val listState = rememberRestorableLazyListState(contentReady)
|
||||
val scope = rememberCoroutineScope()
|
||||
val fill = rememberViewportFill(listState)
|
||||
val density = LocalDensity.current
|
||||
@@ -114,9 +121,9 @@ fun HeroDetailScaffold(
|
||||
val barHeightPx = statusBarTop + with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||
val fadeDistancePx = with(density) { FADE_DISTANCE_DP.dp.toPx() }
|
||||
|
||||
var headerHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var rowHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var controlsTopPx by remember { mutableFloatStateOf(0f) }
|
||||
var headerHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var rowHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var controlsTopPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
val scrollPx by remember {
|
||||
derivedStateOf {
|
||||
val index = listState.firstVisibleItemIndex
|
||||
@@ -206,6 +213,31 @@ fun HeroDetailScaffold(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberRestorableLazyListState(contentReady: Boolean): LazyListState {
|
||||
val state = rememberLazyListState()
|
||||
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
|
||||
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
|
||||
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
|
||||
LaunchedEffect(contentReady) {
|
||||
if (contentReady && pending) {
|
||||
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
|
||||
state.scrollToItem(target, offset)
|
||||
pending = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
|
||||
.collect { (currentIndex, currentOffset) ->
|
||||
if (!pending) {
|
||||
index = currentIndex
|
||||
offset = currentOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroSubtitle(
|
||||
text: String,
|
||||
@@ -291,7 +323,11 @@ fun HeroHeader(
|
||||
modifier =
|
||||
Modifier
|
||||
.heightIn(min = 32.dp)
|
||||
.clickable(onClickLabel = stringResource(R.string.open_artist)) { onSubtitleClick() },
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClickLabel = stringResource(R.string.open_artist),
|
||||
) { onSubtitleClick() },
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
|
||||
|
||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
||||
import androidx.compose.material.icons.rounded.Album
|
||||
import androidx.compose.material.icons.rounded.Cancel
|
||||
@@ -50,6 +51,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadList
|
||||
@@ -197,7 +199,8 @@ fun ItemContextMenu(
|
||||
target: MenuTarget,
|
||||
actions: ItemActions,
|
||||
onDismiss: () -> Unit,
|
||||
onInfo: ((SongModel) -> Unit)? = null,
|
||||
onInfo: ((MenuTarget) -> Unit)? = null,
|
||||
onChooseArtists: ((List<ArtistRef>) -> Unit)? = null,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -210,6 +213,11 @@ fun ItemContextMenu(
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceWith(after: () -> Unit) {
|
||||
onDismiss()
|
||||
after()
|
||||
}
|
||||
|
||||
val queueRef = target.queueRef()
|
||||
|
||||
@Composable
|
||||
@@ -256,18 +264,9 @@ fun ItemContextMenu(
|
||||
}
|
||||
}
|
||||
}
|
||||
target.song.artistRefs.forEach { artist ->
|
||||
actions.viewArtist?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,15 +278,9 @@ fun ItemContextMenu(
|
||||
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
target.album.artistRefs.forEach { artist ->
|
||||
actions.viewArtist?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
ArtistItems(target.album.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,11 +291,17 @@ fun ItemContextMenu(
|
||||
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
|
||||
is MenuTarget.Playlist -> {
|
||||
queueItems()
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -342,6 +341,55 @@ private fun MenuHeader(target: MenuTarget) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistItems(
|
||||
artists: List<ArtistRef>,
|
||||
actions: ItemActions,
|
||||
onChooseArtists: ((List<ArtistRef>) -> Unit)?,
|
||||
dismiss: (() -> Unit) -> Unit,
|
||||
replaceWith: (() -> Unit) -> Unit,
|
||||
) {
|
||||
val view = actions.viewArtist ?: return
|
||||
val distinct = artists.distinctBy { it.id }
|
||||
when (distinct.size) {
|
||||
0 -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
1 -> {
|
||||
val artist = distinct.first()
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (onChooseArtists == null) {
|
||||
distinct.forEach { artist ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val fallback = stringResource(R.string.resources_filter_artist)
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
distinct.joinToString(" \u00B7 ") { it.name.ifBlank { fallback } },
|
||||
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
|
||||
) {
|
||||
replaceWith { onChooseArtists(distinct) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadItem(
|
||||
download: SongDownload?,
|
||||
@@ -439,11 +487,19 @@ private fun MenuItem(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
tint: Color? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(label) },
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
||||
trailingContent = trailing,
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
|
||||
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||
|
||||
@Composable
|
||||
fun AlbumInfoDialog(
|
||||
album: Album,
|
||||
download: ListDownloadStatus?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(stringResource(R.string.resources_sort_by_title), album.name)
|
||||
InfoRow(stringResource(R.string.info_sort_title), album.sortName.takeIfDifferentFrom(album.name))
|
||||
InfoRow(stringResource(R.string.info_album_artists), album.artistRefs.names())
|
||||
InfoRow(stringResource(R.string.info_album_artist_sort), album.albumArtistSort.takeIfDifferentFrom(album.albumArtist))
|
||||
InfoRow(stringResource(R.string.resources_sort_by_year), album.year?.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.resources_filter_genre), album.genre)
|
||||
InfoRow(stringResource(R.string.info_songs), album.songCount.takeIf { it > 0 }?.toString())
|
||||
download?.takeIf { it.total > 0 }?.let { status ->
|
||||
InfoRow(
|
||||
stringResource(R.string.status_downloaded),
|
||||
stringResource(R.string.info_downloaded_count, status.downloaded, status.total),
|
||||
)
|
||||
}
|
||||
InfoRow(stringResource(R.string.info_added), album.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_last_played), album.played?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_plays), album.playCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_starred), if (album.starred != null) stringResource(R.string.info_yes) else null)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistInfoDialog(
|
||||
artist: Artist,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(stringResource(R.string.resources_filter_artist), artist.name)
|
||||
InfoRow(stringResource(R.string.info_sort_name), artist.sortName.takeIfDifferentFrom(artist.name))
|
||||
InfoRow(stringResource(R.string.info_albums), artist.albumCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_songs), artist.songCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_plays), artist.playCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_last_played), artist.played?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_starred), if (artist.starred != null) stringResource(R.string.info_yes) else null)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlaylistInfoDialog(
|
||||
playlist: Playlist,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(stringResource(R.string.resources_sort_by_title), playlist.name)
|
||||
InfoRow(stringResource(R.string.info_owner), playlist.owner)
|
||||
InfoRow(stringResource(R.string.info_shared), if (playlist.shared) stringResource(R.string.info_yes) else null)
|
||||
InfoRow(stringResource(R.string.info_songs), playlist.songCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_length), playlist.duration.takeIf { it > 0 }?.let(DateUtils::formatElapsedTime))
|
||||
InfoRow(stringResource(R.string.info_created), playlist.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_updated), playlist.changed.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun String?.takeIfDifferentFrom(regular: String?): String? =
|
||||
this?.trim()?.takeIf { value -> value.isNotEmpty() && !value.equals(regular?.trim(), ignoreCase = true) }
|
||||
|
||||
@Composable
|
||||
internal fun InfoRow(
|
||||
label: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value.isNullOrBlank()) return
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
||||
)
|
||||
Spacer(Modifier.width(INFO_LABEL_GAP))
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ArtistRef>.names(): String? =
|
||||
map { it.name }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.joinToString(ARTIST_SEPARATOR)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun formatDate(
|
||||
context: Context,
|
||||
epochSeconds: Long,
|
||||
): String =
|
||||
DateUtils.formatDateTime(
|
||||
context,
|
||||
epochSeconds * 1000,
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR,
|
||||
)
|
||||
|
||||
private val INFO_LABEL_WIDTH = 100.dp
|
||||
private val INFO_LABEL_GAP = 12.dp
|
||||
@@ -1,12 +1,28 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
|
||||
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||
|
||||
internal fun Song.artistSubtitle(): String? {
|
||||
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()
|
||||
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 }
|
||||
return buildList {
|
||||
primary?.let(::add)
|
||||
|
||||
@@ -4,12 +4,7 @@ import android.text.format.DateUtils
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -18,8 +13,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -53,8 +46,20 @@ fun SongInfoDialog(
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val multipleArtists = song.artistNames().size > 1
|
||||
val regularArtistSort = song.artist?.takeIf { it.isNotBlank() } ?: song.artistRefs.firstOrNull()?.name
|
||||
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
|
||||
InfoRow(stringResource(R.string.resources_filter_artist), song.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.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
||||
InfoRow(
|
||||
@@ -92,32 +97,6 @@ fun SongInfoDialog(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(
|
||||
label: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value.isNullOrBlank()) return
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val INFO_LABEL_WIDTH = 80.dp
|
||||
|
||||
@Composable
|
||||
private fun downloadStatus(status: DownloadStatus): String =
|
||||
when (status) {
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -30,6 +29,7 @@ import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
@@ -39,16 +39,16 @@ import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistChooserSheet
|
||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.HeroDetailScaffold
|
||||
import com.subtracks.ui.components.HeroHeader
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@@ -67,28 +67,23 @@ fun AlbumDetailRoute(
|
||||
viewModel: AlbumDetailViewModel = koinViewModel(key = albumId) { parametersOf(albumId) },
|
||||
playbackController: PlaybackController = koinInject(),
|
||||
) {
|
||||
val album by viewModel.album.collectAsStateWithLifecycle(initialValue = null)
|
||||
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val album by viewModel.album.collectAsStateWithLifecycle()
|
||||
val songs by viewModel.songs.collectAsStateWithLifecycle()
|
||||
val discs by viewModel.discs.collectAsStateWithLifecycle()
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||
val scope = rememberCoroutineScope()
|
||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
||||
val dismissals = contextMenuHost?.dismissals ?: emptyFlow()
|
||||
val pendingDelete = rememberPendingDownloadDelete(dismissals) { _, _ -> viewModel.downloadedBytes() }
|
||||
var showArtistChooser by remember { mutableStateOf(false) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) {
|
||||
pendingDelete = null
|
||||
showArtistChooser = false
|
||||
}
|
||||
DismissOnRequest(dismissals) { showArtistChooser = false }
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val context = playback.context
|
||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
||||
} else {
|
||||
viewModel.onDownloadAction(action)
|
||||
pendingDelete.onAction(album?.name.orEmpty(), DownloadList.Album, albumId, action) { _, _, requested ->
|
||||
viewModel.onDownloadAction(requested)
|
||||
}
|
||||
}
|
||||
val actions =
|
||||
@@ -141,13 +136,8 @@ fun AlbumDetailRoute(
|
||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
||||
)
|
||||
|
||||
pendingDelete?.let { bytes ->
|
||||
DeleteDownloadsDialog(
|
||||
name = album?.name.orEmpty(),
|
||||
bytes = bytes,
|
||||
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
||||
onDismiss = { pendingDelete = null },
|
||||
)
|
||||
PendingDownloadDeleteConfirmation(pendingDelete) {
|
||||
viewModel.onDownloadAction(BulkDownloadAction.Delete)
|
||||
}
|
||||
|
||||
if (showArtistChooser) {
|
||||
@@ -215,6 +205,7 @@ fun AlbumDetailScreen(
|
||||
artwork = artwork,
|
||||
title = album?.name.orEmpty(),
|
||||
onBack = onBack,
|
||||
contentReady = songs.isNotEmpty(),
|
||||
header = { controlsModifier, topInset ->
|
||||
val artistName = album?.albumArtist?.takeIf { it.isNotBlank() }
|
||||
HeroHeader(
|
||||
|
||||
@@ -15,7 +15,6 @@ import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -35,13 +34,20 @@ class AlbumDetailViewModel(
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val album: Flow<Album?> = sourceId.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||
val album: StateFlow<Album?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val songs: Flow<List<Song>> =
|
||||
val songs: StateFlow<List<Song>> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||
val discs: StateFlow<List<Disc>> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
@@ -55,10 +56,12 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
@@ -107,13 +110,12 @@ import com.subtracks.ui.components.ArtistHeroArt
|
||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.ListDownloadIndicator
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PendingDownloadDelete
|
||||
import com.subtracks.ui.components.bulkRef
|
||||
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import com.subtracks.ui.components.statusBarScrim
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
@@ -126,7 +128,6 @@ import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import com.subtracks.ui.theme.rememberOverlaidNameBusy
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@@ -191,9 +192,9 @@ fun ArtistDetailRoute(
|
||||
viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) },
|
||||
playbackController: PlaybackController = koinInject(),
|
||||
) {
|
||||
val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null)
|
||||
val artistName by viewModel.artistName.collectAsStateWithLifecycle(initialValue = null)
|
||||
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = null)
|
||||
val artist by viewModel.artist.collectAsStateWithLifecycle()
|
||||
val artistName by viewModel.artistName.collectAsStateWithLifecycle()
|
||||
val albums by viewModel.albums.collectAsStateWithLifecycle()
|
||||
val bioSnapshots =
|
||||
remember(viewModel) {
|
||||
viewModel.artistBio.map { BioSnapshot(known = true, present = it != null, biography = it?.biography) }
|
||||
@@ -213,26 +214,13 @@ fun ArtistDetailRoute(
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||
val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
|
||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||
val actions =
|
||||
ItemActions(
|
||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||
bulkDownload = { target, action ->
|
||||
target.bulkRef()?.let { (list, refId) ->
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch {
|
||||
pendingDelete =
|
||||
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
|
||||
}
|
||||
} else {
|
||||
viewModel.onDownloadAction(list, refId, action)
|
||||
}
|
||||
}
|
||||
},
|
||||
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
|
||||
setStar = setStar,
|
||||
viewAlbum = onViewAlbum,
|
||||
)
|
||||
@@ -263,13 +251,8 @@ fun ArtistDetailRoute(
|
||||
onShuffle = viewModel::shuffle,
|
||||
)
|
||||
|
||||
pendingDelete?.let { pending ->
|
||||
DeleteDownloadsDialog(
|
||||
name = pending.name,
|
||||
bytes = pending.bytes,
|
||||
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
|
||||
onDismiss = { pendingDelete = null },
|
||||
)
|
||||
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
|
||||
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +285,7 @@ fun ArtistDetailScreen(
|
||||
onCollapseBio: () -> Unit = {},
|
||||
onShuffle: () -> Unit = {},
|
||||
) {
|
||||
val listState = rememberLazyGridState()
|
||||
val listState = rememberRestorableLazyGridState(albumsLoaded)
|
||||
val fill = rememberViewportFill(listState)
|
||||
val density = LocalDensity.current
|
||||
val lineHeight =
|
||||
@@ -349,8 +332,8 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
var imageHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var albumHeightPx by remember { mutableFloatStateOf(0f) }
|
||||
var imageHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
var albumHeightPx by rememberSaveable { mutableFloatStateOf(0f) }
|
||||
val albumRowGapPx = with(density) { ROW_GAP.toPx() }
|
||||
val scrollPx by remember {
|
||||
derivedStateOf {
|
||||
@@ -570,6 +553,31 @@ fun ArtistDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberRestorableLazyGridState(contentReady: Boolean): LazyGridState {
|
||||
val state = rememberLazyGridState()
|
||||
var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) }
|
||||
var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) }
|
||||
var pending by remember { mutableStateOf(index > 0 || offset > 0) }
|
||||
LaunchedEffect(contentReady) {
|
||||
if (contentReady && pending) {
|
||||
val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0))
|
||||
state.scrollToItem(target, offset)
|
||||
pending = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset }
|
||||
.collect { (currentIndex, currentOffset) ->
|
||||
if (!pending) {
|
||||
index = currentIndex
|
||||
offset = currentOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// Only the lines that overlap the action buttons are narrowed; later lines use the full width.
|
||||
@Composable
|
||||
private fun ArtistBio(
|
||||
|
||||
@@ -17,7 +17,6 @@ import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -26,8 +25,6 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -41,23 +38,26 @@ class ArtistDetailViewModel(
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val artist: Flow<Artist?> = sourceId.flatMapLatest { libraryRepository.artist(it, artistId) }
|
||||
val artist: StateFlow<Artist?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.artist(it, artistId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val artistName: Flow<String?> =
|
||||
sourceId.flatMapLatest { id ->
|
||||
combine(
|
||||
libraryRepository.artist(id, artistId),
|
||||
libraryRepository.artistName(id, artistId),
|
||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
||||
}
|
||||
val artistName: StateFlow<String?> =
|
||||
sourceId
|
||||
.flatMapLatest { id ->
|
||||
combine(
|
||||
libraryRepository.artist(id, artistId),
|
||||
libraryRepository.artistName(id, artistId),
|
||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||
|
||||
val albums: Flow<ArtistAlbums?> =
|
||||
val albums: StateFlow<ArtistAlbums?> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
||||
.map<ArtistAlbums, ArtistAlbums?> { it }
|
||||
.onStart { emit(null) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||
sourceId
|
||||
@@ -69,7 +69,10 @@ class ArtistDetailViewModel(
|
||||
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
||||
.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)
|
||||
val bioLoading: StateFlow<Boolean> = _bioLoading
|
||||
@@ -142,15 +145,24 @@ class ArtistDetailViewModel(
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
artist.filterNotNull(),
|
||||
sourceRepository.offline,
|
||||
sourceRepository.activeSource,
|
||||
) { loaded, _, activeSource -> loaded to activeSource }
|
||||
.collectLatest { (loaded, activeSource) ->
|
||||
if (activeSource == null) return@collectLatest
|
||||
val hero = sourceRepository.artistArt(loaded, forIcon = false)
|
||||
val icon = sourceRepository.artistArt(loaded, forIcon = true)
|
||||
sourceRepository.activeSource
|
||||
.filterNotNull()
|
||||
.flatMapLatest { sourceId ->
|
||||
combine(
|
||||
libraryRepository.artist(sourceId, artistId),
|
||||
libraryRepository.artistCoverArts(sourceId, artistId),
|
||||
sourceRepository.offline,
|
||||
) { loaded, covers, offline -> ArtistArtInput(loaded, artistId, offline, sourceId, covers) }
|
||||
}.collectLatest { input ->
|
||||
val resolved = resolveArtistArtInput(input)
|
||||
if (resolved == null) {
|
||||
_art.value = null
|
||||
_artThumbnail.value = null
|
||||
_heroArt.value = null
|
||||
return@collectLatest
|
||||
}
|
||||
val hero = sourceRepository.artistArt(resolved, forIcon = false)
|
||||
val icon = sourceRepository.artistArt(resolved, forIcon = true)
|
||||
_art.value = hero.image ?: hero.covers.firstOrNull()
|
||||
_artThumbnail.value = icon.image ?: icon.covers.firstOrNull()
|
||||
_heroArt.value = hero
|
||||
@@ -169,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -43,6 +45,7 @@ import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
@@ -65,13 +68,11 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
@@ -94,12 +95,14 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.PagingData
|
||||
@@ -122,13 +125,12 @@ import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PendingDownloadDelete
|
||||
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||
import com.subtracks.ui.components.SearchField
|
||||
import com.subtracks.ui.components.bulkRef
|
||||
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||
import com.subtracks.ui.components.statusBarScrim
|
||||
import com.subtracks.ui.home.HomeRoute
|
||||
import com.subtracks.ui.home.HomeSection
|
||||
@@ -139,7 +141,6 @@ import com.subtracks.ui.theme.playerSurfaceColor
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import kotlin.math.abs
|
||||
@@ -274,26 +275,13 @@ fun LibraryRoute(
|
||||
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
|
||||
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
||||
val downloads = downloadsState?.value ?: emptyMap()
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
|
||||
val itemActions =
|
||||
ItemActions(
|
||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||
bulkDownload = { target, action ->
|
||||
target.bulkRef()?.let { (list, refId) ->
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch {
|
||||
pendingDelete =
|
||||
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
|
||||
}
|
||||
} else {
|
||||
viewModel.onDownloadAction(list, refId, action)
|
||||
}
|
||||
}
|
||||
},
|
||||
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
|
||||
setStar = setStar,
|
||||
viewAlbum = onViewAlbum,
|
||||
viewArtist = onViewArtist,
|
||||
@@ -373,13 +361,8 @@ fun LibraryRoute(
|
||||
},
|
||||
)
|
||||
|
||||
pendingDelete?.let { pending ->
|
||||
DeleteDownloadsDialog(
|
||||
name = pending.name,
|
||||
bytes = pending.bytes,
|
||||
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
|
||||
onDismiss = { pendingDelete = null },
|
||||
)
|
||||
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
|
||||
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,8 +575,6 @@ fun LibraryScreen(
|
||||
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
||||
},
|
||||
onOpenSettings = onOpenSettings,
|
||||
offline = offline,
|
||||
onExitOffline = onExitOffline,
|
||||
artwork = artwork,
|
||||
syncing = syncing,
|
||||
)
|
||||
@@ -615,11 +596,29 @@ fun LibraryScreen(
|
||||
}
|
||||
|
||||
if (!searchActive) {
|
||||
val fabContainerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val fabContentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground
|
||||
if (offline) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onExitOffline,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 16.dp, bottom = tabBarHeight + 16.dp)
|
||||
.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CloudOff,
|
||||
contentDescription = stringResource(R.string.offline_mode_status),
|
||||
modifier = Modifier.size(24.dp).offset(y = (-0.75).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
val searchFab = selectedTab == LibraryTab.Home
|
||||
FloatingActionButton(
|
||||
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
||||
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground,
|
||||
containerColor = fabContainerColor,
|
||||
contentColor = fabContentColor,
|
||||
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
||||
) {
|
||||
@@ -638,11 +637,18 @@ fun LibraryScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 4.dp, y = (-4).dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
.offset(x = 1.dp, y = 2.5.dp)
|
||||
.size(11.dp)
|
||||
.background(fabContainerColor, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.background(fabContentColor, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -702,7 +708,7 @@ private fun ListOptionsSheet(
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -824,6 +830,7 @@ private fun ListOptionsSheet(
|
||||
|
||||
private val BOTTOM_CLEARANCE = 80.dp
|
||||
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
|
||||
private val TAB_STRIP_LEADING_GAP = 8.dp
|
||||
private val TAB_ICON_SIZE = 24.dp
|
||||
private val TAB_VERTICAL_PADDING = 6.dp
|
||||
private val TOUCH_TARGET = 48.dp
|
||||
@@ -856,13 +863,12 @@ internal fun LibraryTabs(
|
||||
pagerState: PagerState,
|
||||
onTabSelected: (LibraryTab) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
offline: Boolean = false,
|
||||
onExitOffline: () -> Unit = {},
|
||||
artwork: ArtworkColors?,
|
||||
syncing: Boolean = false,
|
||||
) {
|
||||
val indicatorStretch = 18.dp
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
||||
val page = pagerState.currentPage
|
||||
val fraction = pagerState.currentPageOffsetFraction
|
||||
@@ -894,79 +900,113 @@ internal fun LibraryTabs(
|
||||
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
|
||||
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() }
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
var viewportWidth by remember { mutableIntStateOf(0) }
|
||||
var contentWidth by remember { mutableIntStateOf(0) }
|
||||
var revealed by remember { mutableStateOf(false) }
|
||||
val leadingGap = with(density) { TAB_STRIP_LEADING_GAP.toPx() }
|
||||
LaunchedEffect(pagerState.settledPage, viewportWidth, contentWidth, layoutDirection, bounds[pagerState.settledPage]) {
|
||||
if (viewportWidth == 0) return@LaunchedEffect
|
||||
val settled = bounds[pagerState.settledPage] ?: return@LaunchedEffect
|
||||
val start = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.right else leadingGap + settled.left
|
||||
val end = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.left else leadingGap + settled.right
|
||||
val target =
|
||||
when {
|
||||
start - leadingGap < scrollState.value -> start - leadingGap
|
||||
end > scrollState.value + viewportWidth -> end - viewportWidth
|
||||
else -> null
|
||||
}
|
||||
if (target != null) {
|
||||
val offset = target.roundToInt().coerceIn(0, scrollState.maxValue)
|
||||
if (revealed) scrollState.animateScrollTo(offset) else scrollState.scrollTo(offset)
|
||||
}
|
||||
revealed = true
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(headerColor)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
.padding(top = 2.dp, end = 8.dp, bottom = 2.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.drawBehind {
|
||||
if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) {
|
||||
val height = tabContentHeightPx.coerceAtMost(current.height)
|
||||
drawRoundRect(
|
||||
color = indicatorColor,
|
||||
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
|
||||
size = Size(indicatorRight - indicatorLeft, height),
|
||||
cornerRadius = CornerRadius(8.dp.toPx()),
|
||||
)
|
||||
}
|
||||
},
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onSizeChanged { viewportWidth = it.width }
|
||||
.horizontalScroll(scrollState)
|
||||
.onSizeChanged { contentWidth = it.width },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||
TabButton(
|
||||
tab = tab,
|
||||
artwork = artwork,
|
||||
progress =
|
||||
when (index) {
|
||||
page -> 1f - transition
|
||||
page + (if (fraction >= 0f) 1 else -1) -> transition
|
||||
else -> 0f
|
||||
Spacer(Modifier.width(TAB_STRIP_LEADING_GAP))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.drawBehind {
|
||||
if (current != null &&
|
||||
indicatorLeft != null &&
|
||||
indicatorRight != null &&
|
||||
indicatorRight > indicatorLeft
|
||||
) {
|
||||
val height = tabContentHeightPx.coerceAtMost(current.height)
|
||||
drawRoundRect(
|
||||
color = indicatorColor,
|
||||
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
|
||||
size = Size(indicatorRight - indicatorLeft, height),
|
||||
cornerRadius = CornerRadius(8.dp.toPx()),
|
||||
)
|
||||
}
|
||||
},
|
||||
indicatorLeft = indicatorLeft,
|
||||
indicatorRight = indicatorRight,
|
||||
onClick = { onTabSelected(tab) },
|
||||
modifier =
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
bounds[index] = coordinates.boundsInParent()
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (offline) {
|
||||
IconButton(onClick = onExitOffline) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CloudOff,
|
||||
contentDescription = stringResource(R.string.offline_mode_status),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
if (syncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||
TabButton(
|
||||
tab = tab,
|
||||
artwork = artwork,
|
||||
progress =
|
||||
when (index) {
|
||||
page -> 1f - transition
|
||||
page + (if (fraction >= 0f) 1 else -1) -> transition
|
||||
else -> 0f
|
||||
},
|
||||
indicatorLeft = indicatorLeft,
|
||||
indicatorRight = indicatorRight,
|
||||
onClick = { onTabSelected(tab) },
|
||||
modifier =
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
bounds[index] = coordinates.boundsInParent()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
if (syncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -18,6 +17,7 @@ import androidx.paging.compose.itemKey
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSongItem
|
||||
@@ -27,16 +27,16 @@ import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.HeroDetailScaffold
|
||||
import com.subtracks.ui.components.HeroHeader
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@@ -55,21 +55,17 @@ fun PlaylistDetailRoute(
|
||||
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
||||
playbackController: PlaybackController = koinInject(),
|
||||
) {
|
||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle()
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||
val scope = rememberCoroutineScope()
|
||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow()) { _, _ -> viewModel.downloadedBytes() }
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val context = playback.context
|
||||
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
||||
} else {
|
||||
viewModel.onDownloadAction(action)
|
||||
pendingDelete.onAction(playlist?.name.orEmpty(), DownloadList.Playlist, playlistId, action) { _, _, requested ->
|
||||
viewModel.onDownloadAction(requested)
|
||||
}
|
||||
}
|
||||
val actions =
|
||||
@@ -104,13 +100,8 @@ fun PlaylistDetailRoute(
|
||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
|
||||
)
|
||||
|
||||
pendingDelete?.let { bytes ->
|
||||
DeleteDownloadsDialog(
|
||||
name = playlist?.name.orEmpty(),
|
||||
bytes = bytes,
|
||||
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
||||
onDismiss = { pendingDelete = null },
|
||||
)
|
||||
PendingDownloadDeleteConfirmation(pendingDelete) {
|
||||
viewModel.onDownloadAction(BulkDownloadAction.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +129,7 @@ fun PlaylistDetailScreen(
|
||||
artwork = artwork,
|
||||
title = playlist?.name.orEmpty(),
|
||||
onBack = onBack,
|
||||
contentReady = songs.itemCount > 0,
|
||||
header = { controlsModifier, topInset ->
|
||||
HeroHeader(
|
||||
art = coverArt(playlist?.coverArt, false),
|
||||
|
||||
@@ -37,7 +37,10 @@ class PlaylistDetailViewModel(
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val playlist: Flow<Playlist?> = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||
val playlist: StateFlow<Playlist?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val songs: Flow<PagingData<PlaylistSongItem>> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.subtracks.R
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.playback.PlaybackState
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.artistSubtitle
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.ArtworkTheme
|
||||
import com.subtracks.ui.theme.playerSurfaceColor
|
||||
@@ -139,7 +140,7 @@ fun MiniPlayer(
|
||||
modifier = Modifier.basicMarquee(),
|
||||
)
|
||||
Text(
|
||||
text = item.artist ?: item.album.orEmpty(),
|
||||
text = artistSubtitle(item.artist, item.artists) ?: item.album.orEmpty(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -90,6 +90,7 @@ import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.artistSubtitle
|
||||
import com.subtracks.ui.components.prefetchArtworkRatio
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||
@@ -576,7 +577,10 @@ fun NowPlayingScreen(
|
||||
}
|
||||
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
|
||||
Text(
|
||||
text = state.item?.artist.orEmpty(),
|
||||
text =
|
||||
state.item
|
||||
?.let { artistSubtitle(it.artist, it.artists) }
|
||||
.orEmpty(),
|
||||
style = subtitleStyle,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -74,6 +74,7 @@ import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
||||
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.log.Log
|
||||
@@ -101,6 +102,7 @@ private enum class SettingsDialog {
|
||||
SyncMode,
|
||||
ReplayGain,
|
||||
ReplayGainPreamp,
|
||||
SortNames,
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -128,6 +130,7 @@ fun SettingsRoute(
|
||||
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
||||
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
|
||||
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
|
||||
val sortNameMode by viewModel.sortNameMode.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
SettingsScreen(
|
||||
@@ -143,6 +146,7 @@ fun SettingsRoute(
|
||||
scrobbling = scrobbling,
|
||||
replayGainMode = replayGainMode,
|
||||
replayGainPreamp = replayGainPreamp,
|
||||
sortNameMode = sortNameMode,
|
||||
keepScreenOn = keepScreenOn,
|
||||
offline = offline,
|
||||
verboseLogging = verboseLogging,
|
||||
@@ -158,6 +162,7 @@ fun SettingsRoute(
|
||||
onScrobblingChange = viewModel::setScrobbling,
|
||||
onReplayGainModeChange = viewModel::setReplayGainMode,
|
||||
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
|
||||
onSortNameModeChange = viewModel::setSortNameMode,
|
||||
onKeepScreenOnChange = viewModel::setKeepScreenOn,
|
||||
onOfflineChange = viewModel::setOfflineMode,
|
||||
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||
@@ -205,6 +210,7 @@ fun SettingsScreen(
|
||||
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
|
||||
replayGainPreamp: Float = 0f,
|
||||
sortNameMode: SortNameMode = SortNameMode.Name,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onEditServer: (Long) -> Unit,
|
||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||
@@ -217,6 +223,7 @@ fun SettingsScreen(
|
||||
onScrobblingChange: (Boolean) -> Unit,
|
||||
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
|
||||
onReplayGainPreampChange: (Float) -> Unit = {},
|
||||
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
||||
onKeepScreenOnChange: (Boolean) -> Unit = {},
|
||||
onOfflineChange: (Boolean) -> Unit = {},
|
||||
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||
@@ -348,6 +355,15 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.settings_library_name)) }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_sort_names_title)) },
|
||||
supportingContent = { Text(sortNameModeLabel(LocalResources.current, sortNameMode)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.SortNames },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
||||
item {
|
||||
ListItem(
|
||||
@@ -603,6 +619,14 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SettingsDialog.SortNames -> {
|
||||
SortNameModeDialog(
|
||||
selected = sortNameMode,
|
||||
onSelect = onSortNameModeChange,
|
||||
onDismiss = { dialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
@@ -756,6 +780,52 @@ private fun SyncModeDialog(
|
||||
)
|
||||
}
|
||||
|
||||
private val sortNameModeOptions =
|
||||
listOf(SortNameMode.Name, SortNameMode.SortName, SortNameMode.NonLatin)
|
||||
|
||||
private fun sortNameModeLabel(
|
||||
resources: Resources,
|
||||
mode: SortNameMode,
|
||||
): String =
|
||||
when (mode) {
|
||||
SortNameMode.SortName -> resources.getString(R.string.sort_name_mode_sort_name)
|
||||
SortNameMode.Name -> resources.getString(R.string.sort_name_mode_name)
|
||||
SortNameMode.NonLatin -> resources.getString(R.string.sort_name_mode_non_latin)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SortNameModeDialog(
|
||||
selected: SortNameMode,
|
||||
onSelect: (SortNameMode) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
var draft by remember(selected) { mutableStateOf(selected) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_sort_names_title)) },
|
||||
text = {
|
||||
ChoiceGroup(
|
||||
header = stringResource(R.string.settings_sort_names_prompt),
|
||||
options = sortNameModeOptions.map { it to sortNameModeLabel(resources, it) },
|
||||
selected = draft,
|
||||
onSelect = { draft = it },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSelect(draft)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.action_done)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplayGainModeDialog(
|
||||
selected: ReplayGainMode,
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
@@ -76,6 +77,11 @@ class SettingsViewModel(
|
||||
.replayGainPreampDb()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
|
||||
|
||||
val sortNameMode: StateFlow<SortNameMode> =
|
||||
userPreferences
|
||||
.sortNameMode()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SortNameMode.Name)
|
||||
|
||||
val keepScreenOn: StateFlow<Boolean> =
|
||||
userPreferences
|
||||
.keepScreenOn()
|
||||
@@ -143,4 +149,8 @@ class SettingsViewModel(
|
||||
fun setKeepScreenOn(enabled: Boolean) {
|
||||
viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) }
|
||||
}
|
||||
|
||||
fun setSortNameMode(mode: SortNameMode) {
|
||||
viewModelScope.launch { userPreferences.setSortNameMode(mode) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
<string name="settings_about_verbose_logging">Verbose logging</string>
|
||||
<string name="settings_about_verbose_logging_description">Log extra detail for troubleshooting</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_library_name">Library</string>
|
||||
<string name="settings_music_name">Playback</string>
|
||||
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
||||
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
|
||||
@@ -96,6 +97,11 @@
|
||||
<string name="settings_replay_gain_title">ReplayGain</string>
|
||||
<string name="settings_music_options_keep_screen_on_description">Keep the screen on while now playing is open</string>
|
||||
<string name="settings_music_options_keep_screen_on_title">Keep screen on</string>
|
||||
<string name="settings_sort_names_title">Sort names</string>
|
||||
<string name="settings_sort_names_prompt">Choose how names are ordered in album and artist lists.</string>
|
||||
<string name="sort_name_mode_sort_name">Use sort names</string>
|
||||
<string name="sort_name_mode_name">Use regular names</string>
|
||||
<string name="sort_name_mode_non_latin">Non-Latin names only</string>
|
||||
<string name="settings_network_name">Network</string>
|
||||
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
|
||||
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
|
||||
@@ -191,6 +197,24 @@
|
||||
<string name="info_sample_rate_value">%1$d Hz</string>
|
||||
<string name="info_channels">Channels</string>
|
||||
<string name="info_close">Close</string>
|
||||
<string name="info_artists">Artists</string>
|
||||
<string name="info_artists_sort">Artists sort</string>
|
||||
<string name="info_album_artists">Album artists</string>
|
||||
<string name="info_album_artist_sort">Album artist sort</string>
|
||||
<string name="info_sort_title">Sort title</string>
|
||||
<string name="info_artist_sort">Artist sort</string>
|
||||
<string name="info_sort_name">Sort name</string>
|
||||
<string name="info_albums">Albums</string>
|
||||
<string name="info_songs">Songs</string>
|
||||
<string name="info_plays">Plays</string>
|
||||
<string name="info_last_played">Last played</string>
|
||||
<string name="info_added">Added</string>
|
||||
<string name="info_created">Created</string>
|
||||
<string name="info_updated">Updated</string>
|
||||
<string name="info_owner">Owner</string>
|
||||
<string name="info_shared">Shared</string>
|
||||
<string name="info_length">Length</string>
|
||||
<string name="info_downloaded_count">%1$d of %2$d</string>
|
||||
|
||||
<string name="disc_header">Disc %1$d</string>
|
||||
<string name="disc_track">%1$s: Disc %2$d</string>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AlbumCreditsTest {
|
||||
@Test
|
||||
fun everyVariantUsesAllFourCreditPaths() {
|
||||
val variants =
|
||||
listOf(
|
||||
CREDITED_ALBUM_ROWS,
|
||||
DOWNLOADED_ALBUM_ROWS,
|
||||
DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST,
|
||||
DOWNLOADED_COVER_ALBUM_ROWS,
|
||||
ARTIST_COVER_CREDITS,
|
||||
ARTIST_COVER_CREDITS_FOR_ARTIST,
|
||||
ALBUMS_FOR_ARTIST_DOWNLOADED,
|
||||
)
|
||||
variants.forEach { sql ->
|
||||
assertEquals(sql, 1, sql.occurrences("FROM albums"))
|
||||
assertEquals(sql, 1, sql.occurrences("FROM album_artists"))
|
||||
assertEquals(sql, 1, sql.occurrences("FROM songs JOIN albums"))
|
||||
assertEquals(sql, 1, sql.occurrences("FROM song_artists"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun variantsKeepTheirOwnFiltersAndDuplicateSemantics() {
|
||||
assertFalse(CREDITED_ALBUM_ROWS.contains("coverArt IS NOT NULL"))
|
||||
assertFalse(CREDITED_ALBUM_ROWS.contains("song_downloads"))
|
||||
assertTrue(CREDITED_ALBUM_ROWS.contains(" UNION ALL "))
|
||||
assertEquals(4, ARTIST_COVER_CREDITS.occurrences("coverArt IS NOT NULL"))
|
||||
assertEquals(4, ARTIST_COVER_CREDITS_FOR_ARTIST.occurrences("= :artistId"))
|
||||
assertEquals(4, DOWNLOADED_ALBUM_ROWS.occurrences("sd.status = 'Completed'"))
|
||||
assertEquals(4, DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST.occurrences("= :artistId"))
|
||||
assertEquals(4, DOWNLOADED_COVER_ALBUM_ROWS.occurrences("coverArt IS NOT NULL"))
|
||||
assertTrue(DOWNLOADED_ALBUM_ROWS.contains(" UNION "))
|
||||
assertFalse(DOWNLOADED_ALBUM_ROWS.contains(" UNION ALL "))
|
||||
assertFalse(DOWNLOADED_ALBUM_ROWS.contains("creditType"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun consumersEmbedTheSharedVariants() {
|
||||
assertTrue(ALBUMS_FOR_ARTIST.contains(CREDITED_ALBUM_ROWS))
|
||||
assertTrue(ALBUM_CREDIT_ALBUM_IDS.contains(CREDITED_ALBUM_ROWS))
|
||||
assertTrue(ARTIST_AGGREGATES_SQL.contains(CREDITED_ALBUM_ROWS))
|
||||
assertTrue(DOWNLOADED_ARTIST_ALBUMS.contains(DOWNLOADED_ALBUM_ROWS))
|
||||
assertTrue(ALBUMS_FOR_ARTIST_DOWNLOADED.contains(DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST))
|
||||
assertTrue(ARTIST_COVER_ARTS_SQL.contains(ARTIST_COVER_CREDITS_FOR_ARTIST))
|
||||
assertTrue(ARTIST_ART_UPDATE_SQL.contains(ARTIST_COVER_CREDITS))
|
||||
assertTrue(ARTIST_ART_UPDATE_SQL.contains(DOWNLOADED_COVER_ALBUM_ROWS))
|
||||
assertTrue(DOWNLOADED_ARTIST_ART_UPDATE_SQL.contains(DOWNLOADED_COVER_ALBUM_ROWS))
|
||||
}
|
||||
|
||||
private fun String.occurrences(value: String): Int = split(value).size - 1
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.ArtworkSeed
|
||||
import com.subtracks.data.model.Source
|
||||
@@ -22,12 +18,7 @@ class ArtworkSeedDaoTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
}
|
||||
|
||||
@After
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
@@ -27,12 +23,7 @@ class LibraryHomeDaoTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
}
|
||||
|
||||
@After
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
|
||||
internal fun LibraryDao.albumPages(
|
||||
sourceId: Long,
|
||||
starredFilter: Int = 0,
|
||||
search: String = "",
|
||||
sort: AlbumSort = AlbumSort.Name,
|
||||
descending: Boolean = false,
|
||||
downloaded: Boolean = false,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): PagingSource<Int, Album> =
|
||||
if (downloaded) {
|
||||
downloadedAlbumsPaging(
|
||||
albumsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starredFilter,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = true,
|
||||
sortNameMode = sortNameMode,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
albumsPaging(
|
||||
albumsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starredFilter,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = false,
|
||||
sortNameMode = sortNameMode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun LibraryDao.artistPages(
|
||||
sourceId: Long,
|
||||
starredFilter: Int = 0,
|
||||
search: String = "",
|
||||
sort: ArtistSort = ArtistSort.Name,
|
||||
descending: Boolean = false,
|
||||
downloaded: Boolean = false,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): PagingSource<Int, Artist> =
|
||||
if (downloaded) {
|
||||
downloadedArtistsPaging(
|
||||
artistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starredFilter,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = true,
|
||||
sortNameMode = sortNameMode,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
artistsPaging(
|
||||
artistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starredFilter,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = false,
|
||||
sortNameMode = sortNameMode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun LibraryDao.playlistPages(
|
||||
sourceId: Long,
|
||||
search: String = "",
|
||||
sort: PlaylistSort = PlaylistSort.Name,
|
||||
descending: Boolean = false,
|
||||
downloaded: Boolean = false,
|
||||
): PagingSource<Int, Playlist> =
|
||||
if (downloaded) {
|
||||
downloadedPlaylistsPaging(
|
||||
playlistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = true,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
playlistsPaging(
|
||||
playlistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.immediateTransaction
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
@@ -14,6 +11,9 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.source.MusicSource
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.data.sync.SyncService
|
||||
@@ -37,14 +37,8 @@ class QueryTimingMeasurementTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(driver)
|
||||
.addCallback(ArtistSearchTriggerOptimization)
|
||||
.build()
|
||||
db = createTestDatabase(driver)
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -110,28 +104,37 @@ class QueryTimingMeasurementTest {
|
||||
|
||||
private suspend fun measureReads() {
|
||||
val dao = db.libraryDao()
|
||||
timed("albums first page (name)") { dao.albumsByName(1, 0, "").firstPage() }
|
||||
timed("albums first page (artist)") { dao.albumsByArtist(1, 0, "").firstPage() }
|
||||
timed("albums first page (year)") { dao.albumsByYear(1, 0, "").firstPage() }
|
||||
timed("albums first page (added)") { dao.albumsByRecentlyAdded(1, 0, "").firstPage() }
|
||||
timed("albums first page (starred)") { dao.albumsByStarred(1, 0, "").firstPage() }
|
||||
timed("albums first page (frequent)") { dao.albumsByFrequent(1, 0, "").firstPage() }
|
||||
timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() }
|
||||
timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() }
|
||||
timed("artists first page (frequent)") { dao.artistsByFrequent(1, 0, "").firstPage() }
|
||||
timed("albums first page (name)") { dao.albumPages(1, 0, "", sort = AlbumSort.Name).firstPage() }
|
||||
timed("albums first page (name, downloaded)") {
|
||||
dao.albumPages(1, 0, "", sort = AlbumSort.Name, downloaded = true).firstPage()
|
||||
}
|
||||
timed("albums first page (artist)") { dao.albumPages(1, 0, "", sort = AlbumSort.Artist).firstPage() }
|
||||
timed("albums first page (year)") { dao.albumPages(1, 0, "", sort = AlbumSort.Year).firstPage() }
|
||||
timed("albums first page (added)") { dao.albumPages(1, 0, "", sort = AlbumSort.Added).firstPage() }
|
||||
timed("albums first page (starred)") { dao.albumPages(1, 0, "", sort = AlbumSort.Starred).firstPage() }
|
||||
timed("albums first page (frequent)") { dao.albumPages(1, 0, "", sort = AlbumSort.Frequent).firstPage() }
|
||||
timed("albums first page (recent)") { dao.albumPages(1, 0, "", sort = AlbumSort.Recent).firstPage() }
|
||||
timed("artists first page (name)") { dao.artistPages(1, 0, "", sort = ArtistSort.Name).firstPage() }
|
||||
timed("artists first page (name, downloaded)") {
|
||||
dao.artistPages(1, 0, "", sort = ArtistSort.Name, downloaded = true).firstPage()
|
||||
}
|
||||
timed("artists first page (frequent)") { dao.artistPages(1, 0, "", sort = ArtistSort.Frequent).firstPage() }
|
||||
timed("artist albums") { dao.albumsForArtist(1, "ar0").first() }
|
||||
timed("artist name") { dao.artistName(1, "ar0").first() }
|
||||
timed("artist songs") { db.queueDao().artistSongs(1, "ar0", 0, 100) }
|
||||
timed("artist length") { db.queueDao().artistLength(1, "ar0") }
|
||||
timed("artist song ids") { db.queueDao().artistSongIds(1, "ar0") }
|
||||
timed("downloaded artists") { dao.downloadedArtists(1).firstPage() }
|
||||
timed("artists downloaded filter") { dao.artistsByName(1, 0, "", downloadedFilter = 1).firstPage() }
|
||||
timed("artists downloaded filter") { dao.artistPages(1, 0, "", downloaded = true).firstPage() }
|
||||
timed("artist statuses") { db.downloadDao().artistStatuses(1).first() }
|
||||
timed("artist status") { db.downloadDao().artistStatus(1, "ar0").first() }
|
||||
timed("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() }
|
||||
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
|
||||
timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() }
|
||||
timed("albums search short") { dao.albumsByName(1, 0, "al").firstPage() }
|
||||
timed("playlists first page (name)") { dao.playlistPages(1, "", sort = PlaylistSort.Name).firstPage() }
|
||||
timed("playlists first page (name, downloaded)") {
|
||||
dao.playlistPages(1, "", sort = PlaylistSort.Name, downloaded = true).firstPage()
|
||||
}
|
||||
timed("albums deep page") { dao.albumPages(1, 0, "", sort = AlbumSort.Name).deepPage(1000) }
|
||||
timed("albums search FTS") { dao.albumPages(1, 0, "Album 1", sort = AlbumSort.Name).firstPage() }
|
||||
timed("albums search short") { dao.albumPages(1, 0, "al", sort = AlbumSort.Name).firstPage() }
|
||||
timed("songs search FTS") { dao.searchSongs(1, "Song", 50).first() }
|
||||
timed("home recently played") { dao.recentlyPlayedAlbums(1, 20).first() }
|
||||
timed("home most played") { dao.mostPlayedAlbums(1, 20).first() }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.SQLiteDriver
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
|
||||
fun createTestDatabase(driver: SQLiteDriver = BundledSQLiteDriver()): SubtracksDatabase {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
return Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(driver)
|
||||
.addSubtracksDatabaseCallbacks()
|
||||
.build()
|
||||
}
|
||||
@@ -51,6 +51,22 @@ class UserPreferencesTest {
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSortNameModeRoundTrips() =
|
||||
runTest {
|
||||
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val prefs = UserPreferences(store)
|
||||
|
||||
assertEquals(SortNameMode.Name, prefs.sortNameMode().first())
|
||||
|
||||
prefs.setSortNameMode(SortNameMode.NonLatin)
|
||||
|
||||
assertEquals(SortNameMode.NonLatin, prefs.sortNameMode().first())
|
||||
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSelectedLibraryTabRoundTrips() =
|
||||
runTest {
|
||||
|
||||
@@ -3,13 +3,12 @@ package com.subtracks.data.repo
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.net.Uri
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkFetcher
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
@@ -84,11 +83,7 @@ class DownloadRepositoryTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
resources = context.resources
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
sources = SourceRepository(db, OkHttpClient(), prefs, artwork, scope = scope)
|
||||
|
||||
@@ -4,12 +4,11 @@ import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import androidx.paging.PagingDataEvent
|
||||
import androidx.paging.PagingDataPresenter
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
@@ -63,11 +62,7 @@ class LibraryRepositoryTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
resources = context.resources
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
sourceRepository =
|
||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
||||
@@ -76,6 +71,7 @@ class LibraryRepositoryTest {
|
||||
db,
|
||||
sourceRepository,
|
||||
NetworkServerActionSink(sourceRepository),
|
||||
fakeUserPreferences(),
|
||||
showMessage = { messages += it.resolve(resources) },
|
||||
scope = scope,
|
||||
)
|
||||
@@ -207,6 +203,42 @@ class LibraryRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theDownloadedAlbumGridOnlyPresentsDownloadedRows() =
|
||||
runBlocking {
|
||||
val sourceId = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
|
||||
db.libraryDao().upsertAlbums(listOf(seedAlbum(sourceId, 1), seedAlbum(sourceId, 2)))
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
seedSong(sourceId).copy(id = "s1", albumId = "al-0001"),
|
||||
seedSong(sourceId).copy(id = "s2", albumId = "al-0002"),
|
||||
),
|
||||
)
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||
|
||||
val presenterDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
val presenter = RecordingPresenter(presenterDispatcher)
|
||||
val job =
|
||||
launch {
|
||||
repository
|
||||
.albums(
|
||||
sourceId = sourceId,
|
||||
sort = AlbumSort.Name,
|
||||
descending = false,
|
||||
starred = StarredFilter.Any,
|
||||
search = "",
|
||||
downloaded = true,
|
||||
).collect { presenter.collectFrom(it) }
|
||||
}
|
||||
try {
|
||||
awaitRefreshes(presenter, 1)
|
||||
assertEquals(1, presented(presenter, presenterDispatcher))
|
||||
} finally {
|
||||
job.cancelAndJoin()
|
||||
presenterDispatcher.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistAlbumsSeparatesAlbumCreditsFromTrackAppearances() =
|
||||
runBlocking {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package com.subtracks.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.PendingActionKind
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
@@ -82,12 +79,7 @@ class PendingServerActionSinkTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
sourceId = runBlocking { db.sourcesDao().insertSource(Source(name = "server", address = "http://x/", createdAt = 0)) }
|
||||
delegate = RecordingSink()
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
package com.subtracks.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useReaderConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
@@ -26,12 +23,7 @@ class QueueKeysetTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
repository = QueueRepository(db)
|
||||
}
|
||||
|
||||
@@ -158,6 +150,18 @@ class QueueKeysetTest {
|
||||
assertTrue("backward keyset seeks", repository.keysetSeeks > beforeBackward)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scanningAnAlbumWithNullDiscAndTrackForwardFallsBackToOffsetRows() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
repository.replace(listOf(repository.albumEntry(1, "mixed")))
|
||||
val snapshot = repository.snapshot()
|
||||
|
||||
val resolved = (0 until snapshot.size).map { repository.itemAt(snapshot, it)?.song?.id }
|
||||
|
||||
assertEquals(listOf("n1", "n2", "s1", "s2"), resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scanningAnAlbumWithNullDiscAndTrackBackwardsResolvesEveryRow() =
|
||||
runTest {
|
||||
@@ -268,6 +272,22 @@ class QueueKeysetTest {
|
||||
assertTrue(ids.all { it in expected })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thePlaylistWindowQuerySeeksThePositionIndex() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val plan =
|
||||
queryPlan(
|
||||
"SELECT songs.*, playlist_songs.position AS playlistPosition, albums.coverArt AS coverArt " +
|
||||
"FROM playlist_songs JOIN songs ON songs.sourceId = playlist_songs.sourceId " +
|
||||
"AND songs.id = playlist_songs.songId LEFT JOIN albums ON albums.sourceId = songs.sourceId " +
|
||||
"AND albums.id = songs.albumId WHERE playlist_songs.sourceId = 1 " +
|
||||
"AND playlist_songs.playlistId = 'pl1' ORDER BY playlist_songs.position LIMIT 51 OFFSET 100000",
|
||||
)
|
||||
|
||||
assertTrue(plan.joinToString("\n"), plan.any { it.contains("sqlite_autoindex_playlist_songs_1") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theAlbumKeysetQuerySeeksTheOrderIndex() =
|
||||
runTest {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package com.subtracks.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useReaderConnection
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
@@ -33,12 +30,7 @@ class QueueRepositoryTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
repository = QueueRepository(db)
|
||||
}
|
||||
|
||||
@@ -735,6 +727,32 @@ class QueueRepositoryTest {
|
||||
assertEquals(listOf("s1", "s2", "s3"), resolveAll(restored))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shuffledLargeContextWindowOnlyResolvesRequestedRows() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertPlaylists(listOf(playlist("huge", "Huge")))
|
||||
db.useWriterConnection { transactor ->
|
||||
transactor.usePrepared(
|
||||
"WITH RECURSIVE n(x) AS (VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 199999) " +
|
||||
"INSERT INTO playlist_songs SELECT 1, 'huge', 's1', x FROM n",
|
||||
) { statement ->
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
repository.snapshotAfter(listOf(repository.playlistEntry(1, "huge")))
|
||||
repository.setShuffle(42L)
|
||||
val snapshot = repository.snapshot()
|
||||
|
||||
val window = repository.window(snapshot, center = 100_000, radius = 25)
|
||||
|
||||
assertEquals(200_000L, snapshot.contextSize)
|
||||
assertEquals(51, window.size)
|
||||
assertEquals(51L, repository.resolvedRowCount)
|
||||
assertEquals((99_975L..100_025L).toList(), window.map { it.position })
|
||||
assertTrue(window.all { it.item.song.id == "s1" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shuffledRangeResolvesEntriesInPositionOrder() =
|
||||
runTest {
|
||||
@@ -859,6 +877,55 @@ class QueueRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextIndexUsesTheFirstDuplicateOrdinalAcrossSparsePositions() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
|
||||
db.libraryDao().upsertPlaylistSongs(
|
||||
listOf(
|
||||
PlaylistSong(1, "duplicates", "s2", 10),
|
||||
PlaylistSong(1, "duplicates", "s1", 20),
|
||||
PlaylistSong(1, "duplicates", "s2", 30),
|
||||
),
|
||||
)
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "duplicates")))
|
||||
|
||||
assertEquals(0L, repository.combinedContextIndexOf(snapshot, "s2"))
|
||||
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextIndexFindsADuplicateInsideTheEntryRange() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
|
||||
db.libraryDao().upsertPlaylistSongs(
|
||||
listOf(
|
||||
PlaylistSong(1, "duplicates", "s2", 10),
|
||||
PlaylistSong(1, "duplicates", "s1", 20),
|
||||
PlaylistSong(1, "duplicates", "s2", 30),
|
||||
),
|
||||
)
|
||||
val entry = repository.playlistEntry(1, "duplicates").copy(rangeStart = 1, rangeEnd = 2)
|
||||
val snapshot = repository.snapshotAfter(listOf(entry))
|
||||
|
||||
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextIndexFallsBackToBoundedRowsForGenre() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
insertGenre("s1", "Rock")
|
||||
insertGenre("s3", "Rock")
|
||||
insertGenre("s4", "Rock")
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.genreEntry(1, "Rock")))
|
||||
|
||||
assertEquals(2L, repository.combinedContextIndexOf(snapshot, "s4"))
|
||||
assertTrue(repository.resolvedRowCount <= QUEUE_CHUNK)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upNextCombinedIndexOfCountsFromTheAnchorIntoTheBlock() =
|
||||
runTest {
|
||||
|
||||
@@ -2,13 +2,12 @@ package com.subtracks.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.TEST_TIMEOUT_MS
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
@@ -58,11 +57,7 @@ class SourceRepositoryTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
resources = context.resources
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
prefs = fakeUserPreferences()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@@ -314,7 +309,7 @@ class SourceRepositoryTest {
|
||||
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.activeSource.first { it != null }!! }
|
||||
|
||||
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
|
||||
withTimeout(TEST_TIMEOUT_MS) {
|
||||
|
||||
@@ -745,6 +745,29 @@ class SubsonicXmlTest {
|
||||
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsPlaylistOwnerAndSharing() {
|
||||
val playlists =
|
||||
parse(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<playlists>
|
||||
<playlist id="pl1" name="Shared" owner="austin" public="true" songCount="2" duration="120"/>
|
||||
<playlist id="pl2" name="Private" owner="austin" public="false" songCount="1" duration="60"/>
|
||||
<playlist id="pl3" name="Numeric" owner="austin" public="1" songCount="1" duration="60"/>
|
||||
</playlists>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
tag = "playlist",
|
||||
create = { SubsonicXml.playlist(1, it) },
|
||||
)
|
||||
|
||||
assertEquals("austin", playlists[0].owner)
|
||||
assertTrue(playlists[0].shared)
|
||||
assertEquals(false, playlists[1].shared)
|
||||
assertTrue(playlists[2].shared)
|
||||
}
|
||||
|
||||
private class CountingInputStream(
|
||||
input: InputStream,
|
||||
) : FilterInputStream(input) {
|
||||
|
||||
@@ -2,13 +2,12 @@ package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.TEST_TIMEOUT_MS
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
@@ -55,11 +54,7 @@ class SyncManagerTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
resources = context.resources
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
sourceRepository =
|
||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.LoggingSQLiteDriver
|
||||
import com.subtracks.data.db.SqlExecution
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.artistsDefaultQuery
|
||||
import com.subtracks.data.db.artistPages
|
||||
import com.subtracks.data.db.artistsPageQuery
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
@@ -43,12 +42,7 @@ class SyncServiceTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -75,7 +69,7 @@ class SyncServiceTest {
|
||||
2,
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.size,
|
||||
)
|
||||
@@ -98,7 +92,7 @@ class SyncServiceTest {
|
||||
listOf("a1"),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.map { it.id },
|
||||
)
|
||||
@@ -172,7 +166,7 @@ class SyncServiceTest {
|
||||
"Old",
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.single()
|
||||
.name,
|
||||
@@ -185,7 +179,7 @@ class SyncServiceTest {
|
||||
"New",
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.single()
|
||||
.name,
|
||||
@@ -258,6 +252,19 @@ class SyncServiceTest {
|
||||
assertEquals(5678L, stored?.starred)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncStoresPlaylistOwnerAndSharing() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source = FakeMusicSource(playlists = listOf(playlist("p1").copy(owner = "austin", shared = true)))
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
val stored = db.libraryDao().playlist(1, "p1").first()
|
||||
assertEquals("austin", stored?.owner)
|
||||
assertEquals(true, stored?.shared)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncStoresSongGenresInOrder() =
|
||||
runTest {
|
||||
@@ -1094,16 +1101,16 @@ class SyncServiceTest {
|
||||
listOf("a1"),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.map { it.id },
|
||||
)
|
||||
val query = artistsDefaultQuery(1, 0, "alpha", ArtistSort.Name, false)
|
||||
val query = artistsPageQuery(1, 0, "alpha", ArtistSort.Name, false, false)
|
||||
assertEquals(
|
||||
listOf("a1"),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "alpha")
|
||||
.artistPages(1, 0, "alpha")
|
||||
.allRows()
|
||||
.map { it.id },
|
||||
)
|
||||
@@ -1190,7 +1197,7 @@ class SyncServiceTest {
|
||||
listOf("a1", "combo"),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.map { it.id },
|
||||
)
|
||||
@@ -1203,7 +1210,7 @@ class SyncServiceTest {
|
||||
listOf("a1"),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.map { it.id },
|
||||
)
|
||||
@@ -1421,7 +1428,7 @@ class SyncServiceTest {
|
||||
SyncService(db, source).sync()
|
||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
|
||||
|
||||
val filtered = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows()
|
||||
val filtered = db.libraryDao().artistPages(1, 0, "", downloaded = true).allRows()
|
||||
assertEquals(listOf("A", "B"), filtered.map { it.name })
|
||||
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
||||
assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first())
|
||||
@@ -1525,7 +1532,7 @@ class SyncServiceTest {
|
||||
albums.size.toLong(),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.first { it.id == "a2" }
|
||||
.albumCount,
|
||||
@@ -1767,7 +1774,7 @@ class SyncServiceTest {
|
||||
assertEquals("$artistId downloaded", downloaded.toLong(), status.downloaded)
|
||||
}
|
||||
|
||||
val downloadedArtists = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows()
|
||||
val downloadedArtists = db.libraryDao().artistPages(1, 0, "", downloaded = true).allRows()
|
||||
for (artist in downloadedArtists) {
|
||||
assertEquals(
|
||||
"${artist.id} downloaded albums",
|
||||
@@ -1832,13 +1839,8 @@ class SyncServiceTest {
|
||||
@Test
|
||||
fun anIdenticalSyncSkipsTheAggregateQueries() =
|
||||
runTest {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val executions = Collections.synchronizedList(mutableListOf<SqlExecution>())
|
||||
val loggingDb =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it })
|
||||
.build()
|
||||
val loggingDb = createTestDatabase(LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it })
|
||||
try {
|
||||
loggingDb.sourcesDao().upsertSource(
|
||||
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.subtracks.playback
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -70,6 +71,21 @@ class MediaItemTest {
|
||||
assertEquals(gain, item.replayGain())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theItemCarriesEveryArtist() {
|
||||
val artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B"))
|
||||
|
||||
val item =
|
||||
queueMediaItem(
|
||||
song.copy(artists = artists),
|
||||
local = null,
|
||||
stream = "http://server/stream?s=s1",
|
||||
transcode = false,
|
||||
)
|
||||
|
||||
assertEquals(artists, item.artistRefs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theItemCarriesItsGainContext() {
|
||||
val item =
|
||||
|
||||
@@ -3,9 +3,7 @@ package com.subtracks.playback
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.R
|
||||
@@ -14,6 +12,7 @@ import com.subtracks.UiMessage
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.model.Album
|
||||
@@ -83,11 +82,7 @@ class PlaybackControllerTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
resources = context.resources
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
prefs = fakeUserPreferences()
|
||||
networkMode = MutableStateFlow(NetworkMode.Wifi)
|
||||
sources =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
@@ -57,6 +58,29 @@ class QueueItemTest {
|
||||
assertEquals(true, item.albumContext)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carriesEveryArtist() {
|
||||
val song =
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = "s1",
|
||||
albumId = "al1",
|
||||
artistId = "ar1",
|
||||
title = "Song",
|
||||
album = "Album",
|
||||
artist = "A • B",
|
||||
duration = 100,
|
||||
track = 1,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
artists = listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")),
|
||||
)
|
||||
|
||||
val item = AlbumSongItem(song, coverArt = null).toQueueItem()
|
||||
|
||||
assertEquals(listOf("A", "B"), item.artists.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSongWithoutGainCarriesNoGain() {
|
||||
val item =
|
||||
|
||||
@@ -40,12 +40,14 @@ import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.prefs.ListQuery
|
||||
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.TestServer
|
||||
import com.subtracks.data.source.subsonic.TestServers
|
||||
import com.subtracks.data.sync.SyncService
|
||||
import com.subtracks.playback.PlaybackState
|
||||
import com.subtracks.playback.QueueItem
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.home.HomeFeed
|
||||
import com.subtracks.ui.home.HomeScreen
|
||||
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`.
|
||||
* The synced database is rebuilt each run; downloaded cover art is cached under the system temp dir
|
||||
* 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)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
|
||||
@Config(qualifiers = "w393dp-h786dp-normal-long-notround-any-440dpi-keyshidden-nonav")
|
||||
class DemoScreenshotTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
@@ -123,6 +129,7 @@ class DemoScreenshotTest {
|
||||
HomeScreen(
|
||||
feed = demo.feed,
|
||||
coverArt = demo.coverArt,
|
||||
artistArt = demo::artistArt,
|
||||
playingSongId = demo.homePlayingSong.id,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
@@ -178,6 +185,7 @@ class DemoScreenshotTest {
|
||||
ArtistDetailScreen(
|
||||
artist = demo.artist,
|
||||
artistName = demo.artist.name,
|
||||
bio = demo.artistBio,
|
||||
albums =
|
||||
ArtistAlbums(
|
||||
albumCredits =
|
||||
@@ -216,6 +224,7 @@ class DemoScreenshotTest {
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
artwork = demo.artwork(demo.nowPlayingAlbum.coverArt),
|
||||
artistArt = demo::artistArt,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
demo.MiniPlayerOverlay(demo.nowPlayingSong, demo.nowPlayingAlbum, progressInset = true)
|
||||
@@ -228,6 +237,7 @@ class DemoScreenshotTest {
|
||||
onQueryChange = {},
|
||||
results = demo.search,
|
||||
coverArt = demo.coverArt,
|
||||
artistArt = demo::artistArt,
|
||||
playingSongId = demo.nowPlayingSong.id,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
@@ -288,7 +298,7 @@ class DemoScreenshotTest {
|
||||
|
||||
private fun reencodeOpaqueRgb(file: 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 {
|
||||
drawImage(decoded, 0, 0, null)
|
||||
dispose()
|
||||
@@ -329,6 +339,7 @@ class DemoScreenshotTest {
|
||||
val homePlayingAlbum: Album,
|
||||
val homePlayingSong: Song,
|
||||
val artist: Artist,
|
||||
val artistBio: String?,
|
||||
val artistAlbums: List<Album>,
|
||||
val search: SearchResults,
|
||||
val coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
@@ -371,6 +382,15 @@ class DemoScreenshotTest {
|
||||
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 {
|
||||
private const val FALLBACK_SEED = 0xFF3A7BD5.toInt()
|
||||
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 homePlayingSong = songsOf(homePlayingAlbum).first()
|
||||
val artist =
|
||||
allArtists.firstOrNull { it.name == "Ugress" }
|
||||
allArtists.firstOrNull { it.name == "Nine Inch Nails" }
|
||||
?: allArtists.firstOrNull { it.name == "pornophonique" }
|
||||
?: allArtists.maxByOrNull { it.albumCount }
|
||||
?: error("demo library has no artists")
|
||||
val artistBio = normalizeBiography(source.artistBiography(artist.id))
|
||||
val artistAlbums = dao.albumsForArtist(1, artist.id).first()
|
||||
|
||||
val coverArtIds =
|
||||
@@ -509,6 +531,7 @@ class DemoScreenshotTest {
|
||||
homePlayingAlbum = homePlayingAlbum,
|
||||
homePlayingSong = homePlayingSong,
|
||||
artist = artist,
|
||||
artistBio = artistBio,
|
||||
artistAlbums = artistAlbums.filter { it.coverArt != null },
|
||||
search = search,
|
||||
coverArt = coverArt,
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.subtracks.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.test.assertWidthIsEqualTo
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
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.swipeLeft
|
||||
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.LoadStates
|
||||
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)
|
||||
}
|
||||
|
||||
@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 =
|
||||
LoadStates(
|
||||
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,79 @@
|
||||
package com.subtracks.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.NetworkServerActionSink
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.FakePlayerConnection
|
||||
import com.subtracks.playback.FakePlayerHandle
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ViewModelRepositoryFixture {
|
||||
val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
|
||||
init {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val db = createTestDatabase()
|
||||
val artworkStore = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
val downloadEngine = FakeDownloadEngine()
|
||||
val downloadsDir = File(context.cacheDir, "downloads-${System.nanoTime()}")
|
||||
val sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artworkStore, scope = repoScope)
|
||||
val downloadRepository =
|
||||
DownloadRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
downloadEngine,
|
||||
downloadsDir,
|
||||
artworkStore = artworkStore,
|
||||
artworkFetcher = { ByteArray(0) },
|
||||
scope = repoScope,
|
||||
)
|
||||
val queueRepository = QueueRepository(db)
|
||||
val controller =
|
||||
PlaybackController(
|
||||
sourceRepository,
|
||||
queueRepository,
|
||||
FakePlayerConnection(FakePlayerHandle()),
|
||||
downloadRepository,
|
||||
scope = controllerScope,
|
||||
)
|
||||
val libraryRepository =
|
||||
LibraryRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
NetworkServerActionSink(sourceRepository),
|
||||
fakeUserPreferences(),
|
||||
scope = repoScope,
|
||||
)
|
||||
|
||||
fun close() {
|
||||
cancelAndJoinBlocking(controllerScope, repoScope)
|
||||
db.close()
|
||||
Dispatchers.resetMain()
|
||||
dispatcher.close()
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,13 @@ 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.BulkDownloadAction
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -55,6 +60,115 @@ class DeleteDownloadsDialogTest {
|
||||
composeRule.onNodeWithText("512 B", substring = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sharedWorkflowLooksUpBytesAndConfirmsTheRequestedTargetOnce() {
|
||||
val actions = mutableListOf<Triple<DownloadList, String, BulkDownloadAction>>()
|
||||
lateinit var controller: PendingDownloadDeleteController
|
||||
renderWorkflow(
|
||||
downloadedBytes = { list, refId ->
|
||||
assertEquals(DownloadList.Album, list)
|
||||
assertEquals("al1", refId)
|
||||
1_500_000
|
||||
},
|
||||
onAction = { list, refId, action -> actions += Triple(list, refId, action) },
|
||||
onController = { controller = it },
|
||||
)
|
||||
|
||||
composeRule.runOnIdle {
|
||||
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> }
|
||||
}
|
||||
composeRule.waitUntil { controller.pending != null }
|
||||
composeRule.onNodeWithText("Kid A", substring = true).assertIsDisplayed()
|
||||
composeRule.onNodeWithText("MB", substring = true).assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Delete").performClick()
|
||||
|
||||
composeRule.runOnIdle {
|
||||
assertEquals(listOf(Triple(DownloadList.Album, "al1", BulkDownloadAction.Delete)), actions)
|
||||
assertNull(controller.pending)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sharedWorkflowCancelDoesNotConfirm() {
|
||||
val actions = mutableListOf<BulkDownloadAction>()
|
||||
lateinit var controller: PendingDownloadDeleteController
|
||||
renderWorkflow(
|
||||
onAction = { _, _, action -> actions += action },
|
||||
onController = { controller = it },
|
||||
)
|
||||
|
||||
composeRule.runOnIdle {
|
||||
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> }
|
||||
}
|
||||
composeRule.waitUntil { controller.pending != null }
|
||||
composeRule.onNodeWithText("Cancel").performClick()
|
||||
|
||||
composeRule.runOnIdle {
|
||||
assertEquals(emptyList<BulkDownloadAction>(), actions)
|
||||
assertNull(controller.pending)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sharedWorkflowDismissesWithTheContextMenu() {
|
||||
val dismissals = MutableSharedFlow<Unit>(replay = 1)
|
||||
lateinit var controller: PendingDownloadDeleteController
|
||||
renderWorkflow(dismissals = dismissals, onController = { controller = it })
|
||||
|
||||
composeRule.runOnIdle {
|
||||
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> }
|
||||
}
|
||||
composeRule.waitUntil { controller.pending != null }
|
||||
composeRule.runOnIdle { dismissals.tryEmit(Unit) }
|
||||
|
||||
composeRule.waitUntil { controller.pending == null }
|
||||
composeRule.onNodeWithText("Delete").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sharedWorkflowDispatchesNonDeleteActionsImmediately() {
|
||||
val actions = mutableListOf<Triple<DownloadList, String, BulkDownloadAction>>()
|
||||
var lookups = 0
|
||||
lateinit var controller: PendingDownloadDeleteController
|
||||
renderWorkflow(
|
||||
downloadedBytes = { _, _ ->
|
||||
lookups++
|
||||
0
|
||||
},
|
||||
onAction = { list, refId, action -> actions += Triple(list, refId, action) },
|
||||
onController = { controller = it },
|
||||
)
|
||||
|
||||
composeRule.runOnIdle {
|
||||
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Download) { list, refId, action ->
|
||||
actions += Triple(list, refId, action)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.runOnIdle {
|
||||
assertEquals(listOf(Triple(DownloadList.Album, "al1", BulkDownloadAction.Download)), actions)
|
||||
assertEquals(0, lookups)
|
||||
assertNull(controller.pending)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderWorkflow(
|
||||
dismissals: kotlinx.coroutines.flow.Flow<Unit> = emptyFlow(),
|
||||
downloadedBytes: suspend (DownloadList, String) -> Long = { _, _ -> 0 },
|
||||
onAction: (DownloadList, String, BulkDownloadAction) -> Unit = { _, _, _ -> },
|
||||
onController: (PendingDownloadDeleteController) -> Unit,
|
||||
) {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
val controller = rememberPendingDownloadDelete(dismissals, downloadedBytes)
|
||||
onController(controller)
|
||||
PendingDownloadDeleteConfirmation(controller) { pending ->
|
||||
onAction(pending.list, pending.refId, BulkDownloadAction.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(
|
||||
bytes: Long = 0,
|
||||
onConfirm: () -> Unit = {},
|
||||
|
||||
@@ -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.setValue
|
||||
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.onNodeWithText
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
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
|
||||
@@ -44,34 +47,69 @@ class ItemContextMenuArtistTest {
|
||||
)
|
||||
|
||||
@Test
|
||||
fun showsOneEntryPerArtistAndOpensTheChosenOne() {
|
||||
var opened: String? = null
|
||||
fun pluralArtistsShareOneEntryThatOpensTheChooser() {
|
||||
var opened: List<ArtistRef>? = null
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
var show by remember { mutableStateOf(true) }
|
||||
if (show) {
|
||||
ItemContextMenu(
|
||||
target = MenuTarget.Song(song),
|
||||
actions = ItemActions(viewArtist = { opened = it }),
|
||||
actions = ItemActions(viewArtist = {}),
|
||||
onDismiss = { show = false },
|
||||
onChooseArtists = { opened = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("A").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("B").assertIsDisplayed()
|
||||
val entry = composeRule.onAllNodesWithText("A · B").filterToOne(hasClickAction())
|
||||
entry.assertIsDisplayed()
|
||||
assertTrue(
|
||||
composeRule
|
||||
.onAllNodesWithText("A")
|
||||
.filter(hasClickAction())
|
||||
.fetchSemanticsNodes()
|
||||
.isEmpty(),
|
||||
)
|
||||
assertTrue(
|
||||
composeRule
|
||||
.onAllNodesWithText("B")
|
||||
.filter(hasClickAction())
|
||||
.fetchSemanticsNodes()
|
||||
.isEmpty(),
|
||||
)
|
||||
|
||||
composeRule.mainClock.autoAdvance = false
|
||||
composeRule.onNodeWithText("B").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
entry.performClick()
|
||||
|
||||
assertEquals("ar2", opened)
|
||||
assertEquals(listOf("ar1", "ar2"), opened?.map { it.id })
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longMenusScrollToTheLastArtist() {
|
||||
fun aSingleArtistOpensDirectly() {
|
||||
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 {
|
||||
SubtracksTheme {
|
||||
ItemContextMenu(
|
||||
@@ -79,18 +117,23 @@ class ItemContextMenuArtistTest {
|
||||
MenuTarget.Song(
|
||||
song.copy(artists = (1..20).map { ArtistRef("ar$it", "Artist $it") }),
|
||||
),
|
||||
actions = ItemActions(viewArtist = { opened = it }),
|
||||
actions = ItemActions(viewArtist = {}),
|
||||
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.onNodeWithText("Artist 20").performClick()
|
||||
composeRule.mainClock.advanceTimeBy(1_000)
|
||||
entry.performClick()
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -1,40 +1,16 @@
|
||||
package com.subtracks.ui.downloads
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.NetworkServerActionSink
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.FakePlayerConnection
|
||||
import com.subtracks.playback.FakePlayerHandle
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import com.subtracks.ui.ViewModelRepositoryFixture
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -42,55 +18,23 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DownloadsViewModelTest {
|
||||
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
|
||||
private lateinit var db: SubtracksDatabase
|
||||
private lateinit var sourceRepository: SourceRepository
|
||||
private lateinit var downloadRepository: DownloadRepository
|
||||
private lateinit var controller: PlaybackController
|
||||
private lateinit var fixture: ViewModelRepositoryFixture
|
||||
private val db get() = fixture.db
|
||||
private val sourceRepository get() = fixture.sourceRepository
|
||||
private val downloadRepository get() = fixture.downloadRepository
|
||||
private val controller get() = fixture.controller
|
||||
private lateinit var viewModel: DownloadsViewModel
|
||||
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private var sourceId: Long = 0
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
|
||||
downloadRepository =
|
||||
DownloadRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
FakeDownloadEngine(),
|
||||
File(context.cacheDir, "downloads-${System.nanoTime()}"),
|
||||
artworkStore = artwork,
|
||||
artworkFetcher = { ByteArray(0) },
|
||||
scope = repoScope,
|
||||
)
|
||||
controller =
|
||||
PlaybackController(
|
||||
sourceRepository,
|
||||
QueueRepository(db),
|
||||
FakePlayerConnection(FakePlayerHandle()),
|
||||
downloadRepository,
|
||||
scope = controllerScope,
|
||||
)
|
||||
fixture = ViewModelRepositoryFixture()
|
||||
viewModel =
|
||||
DownloadsViewModel(
|
||||
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
|
||||
fixture.libraryRepository,
|
||||
downloadRepository,
|
||||
controller,
|
||||
"Unknown artist",
|
||||
@@ -101,10 +45,7 @@ class DownloadsViewModelTest {
|
||||
@After
|
||||
fun tearDown() {
|
||||
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
|
||||
cancelAndJoinBlocking(controllerScope, repoScope)
|
||||
db.close()
|
||||
Dispatchers.resetMain()
|
||||
dispatcher.close()
|
||||
fixture.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -5,14 +5,13 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.TEST_TIMEOUT_MS
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.model.Song
|
||||
@@ -81,11 +80,7 @@ class NowPlayingRouteTest {
|
||||
private fun setUpState() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val artworkStore = ArtworkStore(File(context.cacheDir, "art"))
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
val sources = SourceRepository(db, OkHttpClient(), prefs, artworkStore, scope = repoScope)
|
||||
val queues = QueueRepository(db)
|
||||
downloads =
|
||||
@@ -98,7 +93,7 @@ class NowPlayingRouteTest {
|
||||
artworkFetcher = { ByteArray(0) },
|
||||
scope = repoScope,
|
||||
).also { it.start() }
|
||||
library = LibraryRepository(db, sources, serverActions, scope = repoScope)
|
||||
library = LibraryRepository(db, sources, serverActions, prefs, scope = repoScope)
|
||||
handle = FakePlayerHandle()
|
||||
controller =
|
||||
PlaybackController(
|
||||
|
||||
@@ -2,14 +2,13 @@ package com.subtracks.ui.playback
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.TEST_TIMEOUT_MS
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
@@ -68,11 +67,7 @@ class QueueViewModelTest {
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
sources =
|
||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = repoScope)
|
||||
queues = QueueRepository(db)
|
||||
|
||||
@@ -1,43 +1,19 @@
|
||||
package com.subtracks.ui.search
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.NetworkServerActionSink
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.FakePlayerConnection
|
||||
import com.subtracks.playback.FakePlayerHandle
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import com.subtracks.ui.ViewModelRepositoryFixture
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -45,64 +21,24 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SearchViewModelTest {
|
||||
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
|
||||
private lateinit var db: SubtracksDatabase
|
||||
private lateinit var sourceRepository: SourceRepository
|
||||
private lateinit var fixture: ViewModelRepositoryFixture
|
||||
private val db get() = fixture.db
|
||||
private val sourceRepository get() = fixture.sourceRepository
|
||||
private lateinit var viewModel: SearchViewModel
|
||||
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
|
||||
val downloads =
|
||||
DownloadRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
FakeDownloadEngine(),
|
||||
File(context.cacheDir, "downloads-${System.nanoTime()}"),
|
||||
artworkStore = artwork,
|
||||
artworkFetcher = { ByteArray(0) },
|
||||
scope = repoScope,
|
||||
)
|
||||
val controller =
|
||||
PlaybackController(
|
||||
sourceRepository,
|
||||
QueueRepository(db),
|
||||
FakePlayerConnection(FakePlayerHandle()),
|
||||
downloads,
|
||||
scope = controllerScope,
|
||||
)
|
||||
viewModel =
|
||||
SearchViewModel(
|
||||
LibraryRepository(db, sourceRepository, NetworkServerActionSink(sourceRepository), scope = repoScope),
|
||||
sourceRepository,
|
||||
controller,
|
||||
)
|
||||
fixture = ViewModelRepositoryFixture()
|
||||
viewModel = SearchViewModel(fixture.libraryRepository, sourceRepository, fixture.controller)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
|
||||
cancelAndJoinBlocking(controllerScope, repoScope)
|
||||
db.close()
|
||||
Dispatchers.resetMain()
|
||||
dispatcher.close()
|
||||
fixture.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2,13 +2,12 @@ package com.subtracks.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
@@ -57,11 +56,7 @@ class AddSourceViewModelTest {
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
|
||||
downloadRepository =
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -31,7 +32,7 @@ class SettingsScreenTest {
|
||||
@Test
|
||||
fun theKeepScreenOnRowTurnsTheSettingOn() {
|
||||
var requested: Boolean? = null
|
||||
renderSettings(keepScreenOn = false) { requested = it }
|
||||
renderSettings(keepScreenOn = false, onKeepScreenOnChange = { requested = it })
|
||||
|
||||
composeRule.onNodeWithText("Keep screen on").performClick()
|
||||
composeRule.waitForIdle()
|
||||
@@ -42,7 +43,7 @@ class SettingsScreenTest {
|
||||
@Test
|
||||
fun theKeepScreenOnRowTurnsTheSettingOff() {
|
||||
var requested: Boolean? = null
|
||||
renderSettings(keepScreenOn = true) { requested = it }
|
||||
renderSettings(keepScreenOn = true, onKeepScreenOnChange = { requested = it })
|
||||
|
||||
composeRule.onNodeWithText("Keep screen on").performClick()
|
||||
composeRule.waitForIdle()
|
||||
@@ -53,7 +54,7 @@ class SettingsScreenTest {
|
||||
@Test
|
||||
fun theKeepScreenOnSwitchTurnsTheSettingOn() {
|
||||
var requested: Boolean? = null
|
||||
renderSettings(keepScreenOn = false) { requested = it }
|
||||
renderSettings(keepScreenOn = false, onKeepScreenOnChange = { requested = it })
|
||||
|
||||
composeRule
|
||||
.onNode(isToggleable() and hasAnySibling(hasText("Keep screen on")), useUnmergedTree = true)
|
||||
@@ -63,9 +64,29 @@ class SettingsScreenTest {
|
||||
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(
|
||||
keepScreenOn: Boolean,
|
||||
onKeepScreenOnChange: (Boolean) -> Unit,
|
||||
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
||||
scrollTo: String = "Keep screen on",
|
||||
) {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
@@ -88,6 +109,7 @@ class SettingsScreenTest {
|
||||
onDownloadOverMeteredChange = {},
|
||||
onScrobblingChange = {},
|
||||
onKeepScreenOnChange = onKeepScreenOnChange,
|
||||
onSortNameModeChange = onSortNameModeChange,
|
||||
onAddServer = {},
|
||||
onOpenDownloads = {},
|
||||
onOpenLicenses = {},
|
||||
@@ -97,7 +119,7 @@ class SettingsScreenTest {
|
||||
}
|
||||
}
|
||||
awaitText("Add server")
|
||||
composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText("Keep screen on"))
|
||||
composeRule.onNode(hasScrollToNodeAction()).performScrollToNode(hasText(scrollTo))
|
||||
}
|
||||
|
||||
private fun awaitText(text: String) {
|
||||
|
||||
@@ -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
|
||||
@@ -9,3 +9,5 @@ android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
kotlin.code.style=official
|
||||
|
||||
mavenCentralMirrorUrl=https://maven-central.storage-download.googleapis.com/maven2/
|
||||
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 513 KiB After Width: | Height: | Size: 577 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 469 KiB After Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 371 KiB |
@@ -1,4 +1,5 @@
|
||||
pluginManagement {
|
||||
val mavenCentralMirrorUrl = providers.gradleProperty("mavenCentralMirrorUrl").get()
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
@@ -7,6 +8,9 @@ pluginManagement {
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
maven(mavenCentralMirrorUrl) {
|
||||
name = "MavenCentralMirror"
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
@@ -16,6 +20,9 @@ dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
maven(providers.gradleProperty("mavenCentralMirrorUrl").get()) {
|
||||
name = "MavenCentralMirror"
|
||||
}
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,15 @@ export const LIB = ".integration/music"
|
||||
export const ports = [$NAV_PORT $GONIC_PORT $LMS_PORT $NC_PORT $PRUNE_PORT]
|
||||
|
||||
const MUSIC = [
|
||||
{ id: 197, name: "Ugress - Retroconnaissance EP", songs: 4 }
|
||||
{ id: 199, name: "Ugress - Kosmonaut", songs: 4 }
|
||||
{ id: 321, name: "Brad Sucks - I Don't Know What I'm Doing", songs: 12 }
|
||||
{ id: "7GZYpfn8wypaTR00LWeNI4", name: "Ugress - Retroconnaissance", songs: 4 }
|
||||
{ id: "5gMe1SlZZzuOMkRVWJaodV", name: "Ugress - Kosmonaut", songs: 4 }
|
||||
{ id: "0JnRLI5lo4eNU1pOePqfcG", name: "Brad Sucks - I Don't Know What I'm Doing", songs: 12 }
|
||||
]
|
||||
|
||||
const MUSIC_DOWNLOAD = "https://demo.navidrome.org/rest/download.view?u=demo&p=demo&v=1.16.1&c=subtracks-test&id="
|
||||
|
||||
const MUSIC_ART = "https://demo.navidrome.org/rest/getCoverArt.view?u=demo&p=demo&v=1.16.1&c=subtracks-test&id="
|
||||
|
||||
# Keep in step with TestServers.kt.
|
||||
export def test-servers [] {
|
||||
[
|
||||
@@ -33,17 +37,18 @@ def song-count [dir: string] {
|
||||
}
|
||||
|
||||
export def ensure-music [] {
|
||||
if ($MUSIC | all {|m| (song-count $"($LIB)/($m.id)") == $m.songs }) {
|
||||
if ($MUSIC | all {|m| (song-count $"($LIB)/($m.id)") == $m.songs and ($"($LIB)/($m.id)/folder.jpg" | path exists) }) {
|
||||
print $"using test music in ($LIB)"
|
||||
return
|
||||
}
|
||||
print "downloading test music from demo.subsonic.org (one-time)"
|
||||
print "downloading test music from demo.navidrome.org (one-time)"
|
||||
mkdir $LIB
|
||||
let tmp = (^mktemp -d | str trim)
|
||||
for m in $MUSIC {
|
||||
print $"($m.name)..."
|
||||
^curl -fsS -o $"($tmp)/($m.id).zip" $"http://demo.subsonic.org/rest/download.view?u=guest1&p=guest&v=1.13.0&c=subtracks-test&id=($m.id)"
|
||||
^unzip -q -o $"($tmp)/($m.id).zip" -d $"($LIB)/($m.id)"
|
||||
^curl -fsS -o $"($tmp)/($m.id).zip" $"($MUSIC_DOWNLOAD)($m.id)"
|
||||
^unzip -j -q -o $"($tmp)/($m.id).zip" -d $"($LIB)/($m.id)"
|
||||
^curl -fsS -o $"($LIB)/($m.id)/folder.jpg" $"($MUSIC_ART)($m.id)"
|
||||
}
|
||||
rm -rf $tmp
|
||||
}
|
||||
@@ -163,8 +168,9 @@ export def start-prune [work: string] {
|
||||
let music = ($dir | path join "music")
|
||||
mkdir ($dir | path join "data")
|
||||
mkdir $music
|
||||
^cp -r $"((pwd) | path join $LIB)/197" $music
|
||||
^cp -r $"((pwd) | path join $LIB)/199" $music
|
||||
for m in ($MUSIC | first 2) {
|
||||
^cp -r $"((pwd) | path join $LIB)/($m.id)" $music
|
||||
}
|
||||
|
||||
let daemon = (job spawn { ^navidrome --address 127.0.0.1 --port $PRUNE_PORT --musicfolder $music --datafolder ($dir | path join "data") --loglevel warn out+err> ($dir | path join "navidrome.log") })
|
||||
let ready = (try {
|
||||
@@ -198,12 +204,12 @@ export def prepare-fixtures [server: record] {
|
||||
|
||||
let albums = (poll "albums" {||
|
||||
let items = (sub-get $base $user $pass "getAlbumList2" [["type" "newest"]] | get albumList2.album)
|
||||
if (["Kosmonaut" "Retroconnaissance EP" "I Don't Know What I'm Doing"] | all {|name| $items | any {|i| $i.name == $name } }) { $items } else { null }
|
||||
if (["Kosmonaut" "Retroconnaissance" "I Don't Know What I'm Doing"] | all {|name| $items | any {|i| $i.name == $name } }) { $items } else { null }
|
||||
})
|
||||
|
||||
let ugress = (named-id $artists "Ugress")
|
||||
let kosmo = (named-id $albums "Kosmonaut")
|
||||
let retro = (named-id $albums "Retroconnaissance EP")
|
||||
let retro = (named-id $albums "Retroconnaissance")
|
||||
let dunno = (named-id $albums "I Don't Know What I'm Doing")
|
||||
|
||||
poll "album songs" {||
|
||||
|
||||