mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
Compare commits
139
Commits
b2c66bf03e
...
main
| 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 | ||
|
|
5de5b618d1 | ||
|
|
81d45ad0ad | ||
|
|
5583c9c1d6 | ||
|
|
11a304e7ba | ||
|
|
c8de39acb9 | ||
|
|
77e3fa87f1 | ||
|
|
0a5b3512da | ||
|
|
783d7f2ebc | ||
|
|
18118492c7 | ||
|
|
0d1118011f | ||
|
|
6e60ff6183 | ||
|
|
4a85b23383 | ||
|
|
d038a33b36 | ||
|
|
fb1c2a02ff | ||
|
|
d81efe168e | ||
|
|
0bd8f1bda8 | ||
|
|
14ed037996 | ||
|
|
e32df2fb08 | ||
|
|
43f0721d26 | ||
|
|
3f1d9b0d8d | ||
|
|
8348e048b5 | ||
|
|
77190a2af1 | ||
|
|
028685d62b | ||
|
|
cac1f6af24 | ||
|
|
98398ab2fe | ||
|
|
b379a22517 | ||
|
|
d4ba9c0faf | ||
|
|
889bdb452b | ||
|
|
194787dfcc | ||
|
|
2e54d09acb | ||
|
|
4b3a7d1dfa | ||
|
|
bb71f4889d | ||
|
|
915225f812 | ||
|
|
88e83c2a86 | ||
|
|
de64e8bc69 | ||
|
|
0e90f51cfa | ||
|
|
250d02e7ab | ||
|
|
ab32a7bfbe | ||
|
|
8941eb6702 | ||
|
|
63d140e67e | ||
|
|
5758609c25 | ||
|
|
b8b5594d04 | ||
|
|
4f6a292e74 | ||
|
|
5dd6e8dab9 | ||
|
|
733a4d2941 | ||
|
|
3198a94df3 | ||
|
|
8cdbe64942 | ||
|
|
824579c938 | ||
|
|
0ac67a124d | ||
|
|
ce33a2b6fd | ||
|
|
d908b5b26d | ||
|
|
d03dd982b2 | ||
|
|
65d74faa1d | ||
|
|
e0cfba489e | ||
|
|
916da72339 | ||
|
|
87dbabb496 | ||
|
|
746e2319b9 | ||
|
|
4d08b8fb1e | ||
|
|
f1628439be | ||
|
|
562ca1607b | ||
|
|
6b0e705883 | ||
|
|
7161121922 | ||
|
|
a41f77d907 | ||
|
|
9d9550be48 | ||
|
|
c64cdd5172 | ||
|
|
4761772b9a | ||
|
|
0c2e85134b | ||
|
|
7d8a2dae9f | ||
|
|
891406eab9 | ||
|
|
ba77b31dc4 | ||
|
|
ef0c34d973 | ||
|
|
97d33a781a | ||
|
|
6c3028eeb2 | ||
|
|
61a2472ea9 | ||
|
|
9971128078 | ||
|
|
daf305f680 | ||
|
|
fcfc5561ae | ||
|
|
a562e4b316 | ||
|
|
538cb4177a |
@@ -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) }
|
||||
|
||||
+1812
File diff suppressed because it is too large
Load Diff
+1819
File diff suppressed because it is too large
Load Diff
+1884
File diff suppressed because it is too large
Load Diff
+1891
File diff suppressed because it is too large
Load Diff
+1900
File diff suppressed because it is too large
Load Diff
+1900
File diff suppressed because it is too large
Load Diff
+1943
File diff suppressed because it is too large
Load Diff
+1962
File diff suppressed because it is too large
Load Diff
+1997
File diff suppressed because it is too large
Load Diff
+2009
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+2
-11
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-11
@@ -1,11 +1,7 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.source.subsonic.TestServers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -19,12 +15,7 @@ class SyncServiceIntegrationTest {
|
||||
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
|
||||
runBlocking {
|
||||
servers.forEach { server ->
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val db = createTestDatabase()
|
||||
try {
|
||||
db.sourcesDao().upsertSource(
|
||||
Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0),
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import androidx.room3.RoomRawQuery
|
||||
|
||||
internal val CREDITED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery())
|
||||
internal val DOWNLOADED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true))
|
||||
internal val DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST =
|
||||
albumCreditRows(AlbumCreditQuery(downloaded = true, artistScoped = true, selectAlbums = true))
|
||||
internal val DOWNLOADED_COVER_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true, coverOnly = true))
|
||||
internal val ARTIST_COVER_CREDITS = albumCreditRows(AlbumCreditQuery(coverOnly = true))
|
||||
internal val ARTIST_COVER_CREDITS_FOR_ARTIST = albumCreditRows(AlbumCreditQuery(coverOnly = true, artistScoped = true))
|
||||
|
||||
private data class AlbumCreditQuery(
|
||||
val artistScoped: Boolean = false,
|
||||
val coverOnly: Boolean = false,
|
||||
val downloaded: Boolean = false,
|
||||
val selectAlbums: Boolean = false,
|
||||
)
|
||||
|
||||
private fun albumCreditRows(query: AlbumCreditQuery): String =
|
||||
if (query.selectAlbums) {
|
||||
listOf(
|
||||
primaryAlbumCredits(query),
|
||||
albumRelationCredits(query),
|
||||
trackRelationCredits(query),
|
||||
trackCredits(query),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
primaryAlbumCredits(query),
|
||||
albumRelationCredits(query),
|
||||
trackCredits(query),
|
||||
trackRelationCredits(query),
|
||||
)
|
||||
}.joinToString(if (query.downloaded) " UNION " else " UNION ALL ")
|
||||
|
||||
private fun primaryAlbumCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM albums "
|
||||
query.downloaded -> "SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums "
|
||||
else -> "SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType FROM albums "
|
||||
},
|
||||
)
|
||||
append("WHERE albums.sourceId = :sourceId AND ${query.directArtist("albums.artistId")} ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND $ALBUM_DOWNLOADED ")
|
||||
}.trim()
|
||||
|
||||
private fun albumRelationCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM album_artists "
|
||||
query.downloaded -> "SELECT albums.id, album_artists.artistId FROM album_artists "
|
||||
else -> "SELECT album_artists.artistId, albums.id, 'album' FROM album_artists "
|
||||
},
|
||||
)
|
||||
append("JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId ")
|
||||
append("WHERE album_artists.sourceId = :sourceId ")
|
||||
if (query.artistScoped) append("AND album_artists.artistId = :artistId ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND $ALBUM_DOWNLOADED ")
|
||||
}.trim()
|
||||
|
||||
private fun trackCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM songs "
|
||||
query.downloaded -> "SELECT songs.albumId, songs.artistId FROM songs "
|
||||
else -> "SELECT songs.artistId, songs.albumId, 'track' FROM songs "
|
||||
},
|
||||
)
|
||||
append(SONG_ALBUM_JOIN)
|
||||
if (query.downloaded) append(TRACK_DOWNLOAD_JOIN)
|
||||
append("WHERE songs.sourceId = :sourceId AND ${query.directArtist("songs.artistId")} ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND sd.status = 'Completed' ")
|
||||
}.trim()
|
||||
|
||||
private fun trackRelationCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM song_artists "
|
||||
query.downloaded -> "SELECT songs.albumId, song_artists.artistId FROM song_artists "
|
||||
else -> "SELECT song_artists.artistId, songs.albumId, 'track' FROM song_artists "
|
||||
},
|
||||
)
|
||||
append("JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId ")
|
||||
append(SONG_ALBUM_JOIN)
|
||||
if (query.downloaded) append(TRACK_DOWNLOAD_JOIN)
|
||||
append("WHERE song_artists.sourceId = :sourceId ")
|
||||
if (query.artistScoped) append("AND song_artists.artistId = :artistId ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND sd.status = 'Completed' ")
|
||||
}.trim()
|
||||
|
||||
private fun AlbumCreditQuery.directArtist(column: String): String = if (artistScoped) "$column = :artistId" else "$column IS NOT NULL"
|
||||
|
||||
private fun AlbumCreditQuery.coverFilter(): String = if (coverOnly) "AND albums.coverArt IS NOT NULL " else ""
|
||||
|
||||
private const val SONG_ALBUM_JOIN = "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId "
|
||||
|
||||
private const val TRACK_DOWNLOAD_JOIN = "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id "
|
||||
|
||||
private const val ALBUM_DOWNLOADED =
|
||||
"EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')"
|
||||
|
||||
internal fun downloadedArtistArtQuery(
|
||||
sourceId: Long,
|
||||
artistIds: List<String>,
|
||||
): RoomRawQuery {
|
||||
val bindings = mutableMapOf<String, Any?>("sourceId" to sourceId)
|
||||
val placeholders =
|
||||
artistIds
|
||||
.mapIndexed { index, artistId ->
|
||||
val name = "artistId$index"
|
||||
bindings[name] = artistId
|
||||
":$name"
|
||||
}.ifEmpty { listOf("NULL") }
|
||||
return rawQuery(DOWNLOADED_ARTIST_ART_UPDATE_SQL.replace(":artistIds", placeholders.joinToString(",")), bindings)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import com.subtracks.log.Log
|
||||
|
||||
@@ -11,6 +12,9 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||
.setDriver(driver)
|
||||
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||
.addCallback(SqliteLifecycleLogging)
|
||||
.addSubtracksDatabaseCallbacks()
|
||||
.build()
|
||||
}
|
||||
|
||||
internal fun RoomDatabase.Builder<SubtracksDatabase>.addSubtracksDatabaseCallbacks(): RoomDatabase.Builder<SubtracksDatabase> =
|
||||
addCallback(ArtistSearchTriggerOptimization).addCallback(SqliteLifecycleLogging)
|
||||
|
||||
@@ -114,6 +114,16 @@ class LoggedMigration(
|
||||
}
|
||||
}
|
||||
|
||||
internal object ArtistSearchTriggerOptimization : RoomDatabase.Callback() {
|
||||
override suspend fun onCreate(connection: SQLiteConnection) {
|
||||
optimizeArtistSearchTriggers(connection)
|
||||
}
|
||||
|
||||
override suspend fun onOpen(connection: SQLiteConnection) {
|
||||
if (!artistSearchTriggersOptimized(connection)) optimizeArtistSearchTriggers(connection)
|
||||
}
|
||||
}
|
||||
|
||||
internal object SqliteLifecycleLogging : RoomDatabase.Callback() {
|
||||
override suspend fun onCreate(connection: SQLiteConnection) {
|
||||
Log.i("sql", "created fresh database")
|
||||
|
||||
@@ -13,18 +13,43 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
// A song is attributed to its album artist and to its credited track artist, so a guest artist's
|
||||
// downloads are visible even though they lead no albums of their own.
|
||||
private const val ARTIST_DOWNLOAD_ROWS =
|
||||
"SELECT albums.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||
"FROM songs " +
|
||||
// Starts from songs so status totals include every song the artist is credited on.
|
||||
internal const val ARTIST_DOWNLOAD_ROWS =
|
||||
"SELECT credits.artistId AS id, credits.songId AS songId, song_downloads.status AS status " +
|
||||
"FROM (" + CREDITED_SONG_ROWS + ") credits " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = :sourceId " +
|
||||
"AND song_downloads.songId = credits.songId"
|
||||
|
||||
// Starts from downloads so lists and filters over partially downloaded libraries do not scan songs.
|
||||
// Kept hand-written for that plan.
|
||||
internal const val DOWNLOADED_ARTIST_ROWS =
|
||||
"SELECT albums.artistId AS id, song_downloads.songId AS songId, " +
|
||||
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||
"FROM song_downloads " +
|
||||
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"WHERE song_downloads.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId AS id, songs.id AS songId, song_downloads.status AS status " +
|
||||
"FROM songs " +
|
||||
"SELECT album_artists.artistId AS id, song_downloads.songId AS songId, " +
|
||||
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||
"FROM song_downloads " +
|
||||
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL"
|
||||
"JOIN album_artists ON album_artists.sourceId = albums.sourceId AND album_artists.albumId = albums.id " +
|
||||
"WHERE song_downloads.sourceId = :sourceId " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId AS id, song_downloads.songId AS songId, " +
|
||||
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||
"FROM song_downloads " +
|
||||
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||
"WHERE song_downloads.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT song_artists.artistId AS id, song_downloads.songId AS songId, " +
|
||||
"song_downloads.downloadedAt AS downloadedAt, song_downloads.status AS status " +
|
||||
"FROM song_downloads " +
|
||||
"JOIN songs ON songs.sourceId = song_downloads.sourceId AND songs.id = song_downloads.songId " +
|
||||
"JOIN song_artists ON song_artists.sourceId = songs.sourceId AND song_artists.songId = songs.id " +
|
||||
"WHERE song_downloads.sourceId = :sourceId"
|
||||
|
||||
@Dao
|
||||
interface DownloadDao {
|
||||
@@ -38,10 +63,15 @@ interface DownloadDao {
|
||||
suspend fun all(): List<SongDownload>
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(DISTINCT songId) AS total, " +
|
||||
"SELECT COALESCE((SELECT songCount FROM artists " +
|
||||
"WHERE sourceId = :sourceId AND id = :artistId), 0) AS total, " +
|
||||
"COALESCE(statuses.downloaded, 0) AS downloaded, " +
|
||||
"COALESCE(statuses.downloading, 0) AS downloading " +
|
||||
"FROM (SELECT " +
|
||||
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
||||
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
||||
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") WHERE id = :artistId",
|
||||
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
|
||||
"WHERE id = :artistId AND status IN ('Completed', 'Queued', 'Running')) statuses",
|
||||
)
|
||||
fun artistStatus(
|
||||
sourceId: Long,
|
||||
@@ -89,12 +119,15 @@ interface DownloadDao {
|
||||
fun albumStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
||||
|
||||
@Query(
|
||||
"SELECT id, " +
|
||||
"COUNT(DISTINCT songId) AS total, " +
|
||||
"SELECT statuses.id, artists.songCount AS total, statuses.downloaded, statuses.downloading " +
|
||||
"FROM (SELECT id, " +
|
||||
"COUNT(DISTINCT CASE WHEN status = 'Completed' THEN songId END) AS downloaded, " +
|
||||
"COUNT(DISTINCT CASE WHEN status IN ('Queued', 'Running') THEN songId END) AS downloading " +
|
||||
"FROM (" + ARTIST_DOWNLOAD_ROWS + ") GROUP BY id " +
|
||||
"HAVING downloaded > 0 OR downloading > 0",
|
||||
"FROM (" + DOWNLOADED_ARTIST_ROWS + ") " +
|
||||
"WHERE status IN ('Completed', 'Queued', 'Running') " +
|
||||
"GROUP BY id) statuses " +
|
||||
"JOIN artists ON artists.sourceId = :sourceId AND artists.id = statuses.id " +
|
||||
"WHERE statuses.downloaded > 0 OR statuses.downloading > 0",
|
||||
)
|
||||
fun artistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
|
||||
|
||||
@@ -114,24 +147,30 @@ interface DownloadDao {
|
||||
|
||||
@Query(
|
||||
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
|
||||
"al.artistId AS artistId, ar.name AS artistName, d.status AS status, " +
|
||||
"d.bytes AS bytes, d.total AS total " +
|
||||
"al.artistId AS artistId, COALESCE(ar.name, al.albumArtist) AS artistName, d.status AS status, " +
|
||||
"d.bytes AS bytes, d.total AS total, " +
|
||||
"COALESCE(NULLIF(ar.sortName, ''), NULLIF(al.albumArtistSort, ''), ar.name, al.albumArtist, '') AS artistSort, " +
|
||||
"COALESCE(NULLIF(al.sortName, ''), al.name, '') AS albumSort, " +
|
||||
"COALESCE(NULLIF(s.sortName, ''), s.title, '') AS songSort " +
|
||||
"FROM song_downloads d " +
|
||||
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
||||
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
||||
"LEFT JOIN artists ar ON ar.sourceId = al.sourceId AND ar.id = al.artistId " +
|
||||
"WHERE d.sourceId = :sourceId",
|
||||
"WHERE d.sourceId = :sourceId ORDER BY s.disc, s.track, s.id",
|
||||
)
|
||||
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
||||
|
||||
@Query(
|
||||
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
||||
"al.artistId AS albumArtistId, " +
|
||||
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt " +
|
||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
|
||||
"aar.coverArt AS albumArtistCoverArt " +
|
||||
"FROM song_downloads d " +
|
||||
"LEFT JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
||||
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
|
||||
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId",
|
||||
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId " +
|
||||
"LEFT JOIN artists aar ON aar.sourceId = al.sourceId AND aar.id = al.artistId",
|
||||
)
|
||||
suspend fun artwork(): List<DownloadArtwork>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,45 +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))) "
|
||||
// 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 (? = '' " +
|
||||
"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)) "
|
||||
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,
|
||||
@@ -51,90 +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, name COLLATE NOCASE, id",
|
||||
) { stmt ->
|
||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||
stmt.bindLong(11, 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)
|
||||
}
|
||||
|
||||
internal fun artistsDefaultQuery(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
sort: ArtistSort,
|
||||
descending: Boolean,
|
||||
): RoomRawQuery {
|
||||
val order = artistOrder(sort, descending)
|
||||
return RoomRawQuery("SELECT * $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindLong(1, sourceId)
|
||||
stmt.bindInt(2, starredFilter)
|
||||
stmt.bindInt(3, starredFilter)
|
||||
stmt.bindInt(4, starredFilter)
|
||||
stmt.bindText(5, search)
|
||||
stmt.bindText(6, search)
|
||||
stmt.bindText(7, search)
|
||||
stmt.bindText(8, search)
|
||||
stmt.bindText(9, search)
|
||||
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 -> {
|
||||
|
||||
@@ -394,6 +394,275 @@ val MIGRATION_27_28 =
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_28_29 =
|
||||
object : Migration(28, 29) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `artists` TEXT NOT NULL DEFAULT ''")
|
||||
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `artists` TEXT NOT NULL DEFAULT ''")
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `album_artists` (`sourceId` INTEGER NOT NULL, `albumId` TEXT NOT NULL, " +
|
||||
"`position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
|
||||
"PRIMARY KEY(`sourceId`, `albumId`, `position`), " +
|
||||
"FOREIGN KEY(`sourceId`, `albumId`) REFERENCES `albums`(`sourceId`, `id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_album_artists_sourceId_artistId` ON `album_artists` (`sourceId`, `artistId`)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `song_artists` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, " +
|
||||
"`position` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
|
||||
"PRIMARY KEY(`sourceId`, `songId`, `position`), " +
|
||||
"FOREIGN KEY(`sourceId`, `songId`) REFERENCES `songs`(`sourceId`, `id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_song_artists_sourceId_artistId` ON `song_artists` (`sourceId`, `artistId`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_29_30 =
|
||||
object : Migration(29, 30) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `songCount` INTEGER NOT NULL DEFAULT 0")
|
||||
connection.execSQL(
|
||||
"WITH credited AS (" +
|
||||
"SELECT sourceId, artistId, songId FROM song_artists " +
|
||||
"UNION " +
|
||||
"SELECT sourceId, artistId, id AS songId FROM songs WHERE artistId IS NOT NULL " +
|
||||
"UNION " +
|
||||
"SELECT album_artists.sourceId, album_artists.artistId, songs.id " +
|
||||
"FROM album_artists JOIN songs " +
|
||||
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||
"UNION " +
|
||||
"SELECT albums.sourceId, albums.artistId, songs.id " +
|
||||
"FROM albums JOIN songs ON songs.sourceId = albums.sourceId AND songs.albumId = albums.id " +
|
||||
"WHERE albums.artistId IS NOT NULL" +
|
||||
"), counts AS (" +
|
||||
"SELECT sourceId, artistId AS id, COUNT(*) AS songCount FROM credited GROUP BY sourceId, artistId" +
|
||||
") " +
|
||||
"UPDATE artists SET songCount = c.songCount " +
|
||||
"FROM (SELECT artists.sourceId AS sourceId, artists.id AS id, " +
|
||||
"COALESCE(counts.songCount, 0) AS songCount " +
|
||||
"FROM artists LEFT JOIN counts " +
|
||||
"ON counts.sourceId = artists.sourceId AND counts.id = artists.id) c " +
|
||||
"WHERE artists.sourceId = c.sourceId AND artists.id = c.id " +
|
||||
"AND artists.songCount != c.songCount",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_30_31 =
|
||||
object : Migration(30, 31) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `albumArtistSort` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `sortName` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `artistSort` TEXT NOT NULL DEFAULT '' COLLATE NOCASE")
|
||||
dropSearchIndex(connection, "artist_search")
|
||||
dropSearchIndex(connection, "album_search")
|
||||
dropSearchIndex(connection, "song_search")
|
||||
connection.execSQL("UPDATE `artists` SET `sortName` = `name`")
|
||||
connection.execSQL("UPDATE `albums` SET `sortName` = `name`")
|
||||
connection.execSQL("UPDATE `songs` SET `sortName` = `title`")
|
||||
connection.execSQL(
|
||||
"UPDATE `albums` SET `albumArtistSort` = COALESCE(" +
|
||||
"(SELECT a.`sortName` FROM `artists` a " +
|
||||
"WHERE a.`sourceId` = `albums`.`sourceId` AND a.`id` = `albums`.`artistId`), " +
|
||||
"(SELECT a.`sortName` FROM `album_artists` aa JOIN `artists` a " +
|
||||
"ON a.`sourceId` = aa.`sourceId` AND a.`id` = aa.`artistId` " +
|
||||
"WHERE aa.`sourceId` = `albums`.`sourceId` AND aa.`albumId` = `albums`.`id` " +
|
||||
"ORDER BY aa.`position` LIMIT 1), " +
|
||||
"`albumArtist`, '')",
|
||||
)
|
||||
connection.execSQL(
|
||||
"UPDATE `songs` SET `artistSort` = COALESCE(" +
|
||||
"(SELECT a.`sortName` FROM `artists` a " +
|
||||
"WHERE a.`sourceId` = `songs`.`sourceId` AND a.`id` = `songs`.`artistId`), " +
|
||||
"(SELECT a.`sortName` FROM `song_artists` sa JOIN `artists` a " +
|
||||
"ON a.`sourceId` = sa.`sourceId` AND a.`id` = sa.`artistId` " +
|
||||
"WHERE sa.`sourceId` = `songs`.`sourceId` AND sa.`songId` = `songs`.`id` " +
|
||||
"ORDER BY sa.`position` LIMIT 1), " +
|
||||
"`artist`, '')",
|
||||
)
|
||||
ARTIST_INDEXES.forEach { connection.execSQL("DROP INDEX IF EXISTS `$it`") }
|
||||
ALBUM_INDEXES.forEach { connection.execSQL("DROP INDEX IF EXISTS `$it`") }
|
||||
ARTIST_INDEX_SQL.forEach { connection.execSQL(it) }
|
||||
ALBUM_INDEX_SQL.forEach { connection.execSQL(it) }
|
||||
createSearchIndex(connection, "artist_search", "artists", listOf("name", "sortName"))
|
||||
createSearchIndex(connection, "album_search", "albums", listOf("name", "albumArtist", "sortName", "albumArtistSort"))
|
||||
createSearchIndex(connection, "song_search", "songs", listOf("title", "artist", "sortName", "artistSort"))
|
||||
}
|
||||
}
|
||||
|
||||
private val ARTIST_INDEXES =
|
||||
listOf("index_artists_name", "index_artists_albumCount", "index_artists_starred", "index_artists_frequent", "index_artists_recent")
|
||||
|
||||
private val ALBUM_INDEXES =
|
||||
listOf(
|
||||
"index_albums_name",
|
||||
"index_albums_artist",
|
||||
"index_albums_year",
|
||||
"index_albums_added",
|
||||
"index_albums_starred",
|
||||
"index_albums_frequent",
|
||||
"index_albums_recent",
|
||||
)
|
||||
|
||||
private val ARTIST_INDEX_SQL =
|
||||
listOf(
|
||||
"CREATE INDEX IF NOT EXISTS `index_artists_name` ON `artists` (`sourceId`, `sortName`, `id`)",
|
||||
"CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `artists` " +
|
||||
"(`sourceId` ASC, `albumCount` DESC, `sortName` ASC, `id` ASC)",
|
||||
"CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `artists` " +
|
||||
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
|
||||
"CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `artists` " +
|
||||
"(`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)",
|
||||
"CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `artists` " +
|
||||
"(`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)",
|
||||
)
|
||||
|
||||
private val ALBUM_INDEX_SQL =
|
||||
listOf(
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_name` ON `albums` (`sourceId`, `sortName`, `id`)",
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `albums` " +
|
||||
"(`sourceId`, `albumArtistSort`, `year`, `sortName`, `id`)",
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_year` ON `albums` " +
|
||||
"(`sourceId` ASC, `year` DESC, `sortName` ASC, `id` ASC)",
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_added` ON `albums` " +
|
||||
"(`sourceId` ASC, `created` DESC, `sortName` ASC, `id` ASC)",
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `albums` " +
|
||||
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `albums` " +
|
||||
"(`sourceId` ASC, `playCount` DESC, `sortName` ASC, `id` ASC)",
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `albums` " +
|
||||
"(`sourceId` ASC, `played` DESC, `sortName` ASC, `id` ASC)",
|
||||
)
|
||||
|
||||
private suspend fun dropSearchIndex(
|
||||
connection: SQLiteConnection,
|
||||
table: String,
|
||||
) {
|
||||
listOf("BEFORE_UPDATE", "BEFORE_DELETE", "AFTER_UPDATE", "AFTER_INSERT").forEach {
|
||||
connection.execSQL("DROP TRIGGER IF EXISTS `room_fts_content_sync_${table}_$it`")
|
||||
}
|
||||
connection.execSQL("DROP TABLE IF EXISTS `$table`")
|
||||
}
|
||||
|
||||
val MIGRATION_31_32 =
|
||||
object : Migration(31, 32) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `credited` INTEGER NOT NULL DEFAULT 1")
|
||||
connection.execSQL(
|
||||
"UPDATE `artists` SET `credited` = 0 " +
|
||||
"WHERE EXISTS (SELECT 1 FROM `album_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
|
||||
"AND EXISTS (SELECT 1 FROM `song_artists` WHERE `sourceId` = `artists`.`sourceId`) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM `album_artists` aa " +
|
||||
"WHERE aa.`sourceId` = `artists`.`sourceId` AND aa.`artistId` = `artists`.`id`) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM `song_artists` sa " +
|
||||
"WHERE sa.`sourceId` = `artists`.`sourceId` AND sa.`artistId` = `artists`.`id`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_32_33 =
|
||||
object : Migration(32, 33) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `sources` ADD COLUMN `creditsStale` INTEGER NOT NULL DEFAULT 1")
|
||||
connection.execSQL("DROP INDEX IF EXISTS `index_songs_starred`")
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_songs_starred` ON `songs` " +
|
||||
"(`sourceId` ASC, `starred` DESC, `sortName` ASC, `id` ASC)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Recomputes the artist totals from the same credit unions the sync uses, so badges, the artist
|
||||
// page and the shuffle queue agree for libraries that predate the change.
|
||||
val MIGRATION_33_34 =
|
||||
object : Migration(33, 34) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"WITH credited_songs AS (" +
|
||||
"SELECT songs.`sourceId` AS sourceId, songs.`artistId` AS artistId, " +
|
||||
"songs.`id` AS songId, songs.`playCount` AS playCount, songs.`played` AS played " +
|
||||
"FROM songs WHERE songs.`artistId` IS NOT NULL " +
|
||||
"UNION " +
|
||||
"SELECT song_artists.`sourceId`, song_artists.`artistId`, songs.`id`, " +
|
||||
"songs.`playCount`, songs.`played` " +
|
||||
"FROM song_artists JOIN songs " +
|
||||
"ON songs.`sourceId` = song_artists.`sourceId` AND songs.`id` = song_artists.`songId` " +
|
||||
"UNION " +
|
||||
"SELECT albums.`sourceId`, albums.`artistId`, songs.`id`, " +
|
||||
"songs.`playCount`, songs.`played` " +
|
||||
"FROM albums JOIN songs " +
|
||||
"ON songs.`sourceId` = albums.`sourceId` AND songs.`albumId` = albums.`id` " +
|
||||
"WHERE albums.`artistId` IS NOT NULL " +
|
||||
"UNION " +
|
||||
"SELECT album_artists.`sourceId`, album_artists.`artistId`, songs.`id`, " +
|
||||
"songs.`playCount`, songs.`played` " +
|
||||
"FROM album_artists JOIN songs " +
|
||||
"ON songs.`sourceId` = album_artists.`sourceId` AND songs.`albumId` = album_artists.`albumId`" +
|
||||
"), credited_albums AS (" +
|
||||
"SELECT albums.`sourceId` AS sourceId, albums.`artistId` AS artistId, " +
|
||||
"albums.`id` AS albumId " +
|
||||
"FROM albums WHERE albums.`artistId` IS NOT NULL " +
|
||||
"UNION " +
|
||||
"SELECT album_artists.`sourceId`, album_artists.`artistId`, albums.`id` " +
|
||||
"FROM album_artists JOIN albums " +
|
||||
"ON albums.`sourceId` = album_artists.`sourceId` AND albums.`id` = album_artists.`albumId` " +
|
||||
"UNION " +
|
||||
"SELECT songs.`sourceId`, songs.`artistId`, songs.`albumId` " +
|
||||
"FROM songs JOIN albums " +
|
||||
"ON albums.`sourceId` = songs.`sourceId` AND albums.`id` = songs.`albumId` " +
|
||||
"WHERE songs.`artistId` IS NOT NULL " +
|
||||
"UNION " +
|
||||
"SELECT song_artists.`sourceId`, song_artists.`artistId`, songs.`albumId` " +
|
||||
"FROM song_artists JOIN songs " +
|
||||
"ON songs.`sourceId` = song_artists.`sourceId` AND songs.`id` = song_artists.`songId` " +
|
||||
"JOIN albums ON albums.`sourceId` = songs.`sourceId` AND albums.`id` = songs.`albumId`" +
|
||||
"), totals AS (" +
|
||||
"SELECT sourceId, artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||
"FROM credited_songs GROUP BY sourceId, artistId" +
|
||||
"), album_totals AS (" +
|
||||
"SELECT sourceId, artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||
"FROM credited_albums GROUP BY sourceId, artistId" +
|
||||
") " +
|
||||
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||
"playCount = c.playCount, played = c.played " +
|
||||
"FROM (SELECT artists.`sourceId` AS sourceId, artists.`id` AS id, " +
|
||||
"COALESCE(totals.songCount, 0) AS songCount, " +
|
||||
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played " +
|
||||
"FROM artists LEFT JOIN totals " +
|
||||
"ON totals.sourceId = artists.`sourceId` AND totals.id = artists.`id` " +
|
||||
"LEFT JOIN album_totals " +
|
||||
"ON album_totals.sourceId = artists.`sourceId` AND album_totals.id = artists.`id` " +
|
||||
"WHERE EXISTS (SELECT 1 FROM album_artists WHERE album_artists.`sourceId` = artists.`sourceId`) " +
|
||||
"OR EXISTS (SELECT 1 FROM song_artists WHERE song_artists.`sourceId` = artists.`sourceId`)" +
|
||||
") c " +
|
||||
"WHERE artists.`sourceId` = c.sourceId AND artists.`id` = c.id " +
|
||||
"AND (artists.`songCount` != c.songCount OR artists.`albumCount` != c.albumCount " +
|
||||
"OR artists.`playCount` != c.playCount OR artists.`played` IS NOT c.played)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_34_35 =
|
||||
object : Migration(34, 35) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `artist_bios` (" +
|
||||
"`sourceId` INTEGER NOT NULL, `artistId` TEXT NOT NULL, `biography` TEXT, " +
|
||||
"PRIMARY KEY(`sourceId`, `artistId`), " +
|
||||
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
||||
private suspend fun createSearchIndex(
|
||||
connection: SQLiteConnection,
|
||||
@@ -426,6 +695,80 @@ private suspend fun createSearchIndex(
|
||||
connection.execSQL("INSERT INTO `$table`(`$table`) VALUES('rebuild')")
|
||||
}
|
||||
|
||||
val MIGRATION_35_36 =
|
||||
object : Migration(35, 36) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `artistImageUrl` TEXT")
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `coverArts` TEXT NOT NULL DEFAULT ''")
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `downloadedCoverArts` TEXT NOT NULL DEFAULT ''")
|
||||
optimizeArtistSearchTriggers(connection)
|
||||
backfillArtistArt(connection)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_36_37 =
|
||||
object : Migration(36, 37) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_name_plain` ON `albums` (`sourceId`, `name`, `id`)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_artist_plain` " +
|
||||
"ON `albums` (`sourceId`, `albumArtist`, `year`, `name`, `id`)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_artists_name_plain` ON `artists` (`sourceId`, `name`, `id`)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_37_38 =
|
||||
object : Migration(37, 38) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `owner` TEXT")
|
||||
connection.execSQL("ALTER TABLE `playlists` ADD COLUMN `shared` INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
|
||||
val triggers = mutableListOf<String>()
|
||||
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
|
||||
statement.bindText(1, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||
statement.bindText(2, "room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||
while (statement.step()) triggers += statement.getText(0)
|
||||
}
|
||||
return triggers.size == 2 && triggers.all { it.contains("UPDATE OF `name`, `sortName`") }
|
||||
}
|
||||
|
||||
internal fun optimizeArtistSearchTriggers(connection: SQLiteConnection) {
|
||||
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||
connection.execSQL("DROP TRIGGER IF EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||
connection.execSQL(
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_BEFORE_UPDATE " +
|
||||
"BEFORE UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
|
||||
"DELETE FROM `artist_search` WHERE `rowid`=OLD.`rowid`; END",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_artist_search_AFTER_UPDATE " +
|
||||
"AFTER UPDATE OF `name`, `sortName` ON `artists` BEGIN " +
|
||||
"INSERT INTO `artist_search`(`rowid`, `name`, `sortName`) " +
|
||||
"VALUES (NEW.`rowid`, NEW.`name`, NEW.`sortName`); END",
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun backfillArtistArt(connection: SQLiteConnection) {
|
||||
val sourceIds = mutableListOf<Long>()
|
||||
connection.prepare("SELECT id FROM sources").use { statement ->
|
||||
while (statement.step()) sourceIds += statement.getLong(0)
|
||||
}
|
||||
sourceIds.forEach { sourceId ->
|
||||
connection.prepare(ARTIST_ART_UPDATE_SQL).use { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATIONS: Array<Migration> =
|
||||
arrayOf(
|
||||
MIGRATION_1_2,
|
||||
@@ -455,4 +798,14 @@ val MIGRATIONS: Array<Migration> =
|
||||
MIGRATION_25_26,
|
||||
MIGRATION_26_27,
|
||||
MIGRATION_27_28,
|
||||
MIGRATION_28_29,
|
||||
MIGRATION_29_30,
|
||||
MIGRATION_30_31,
|
||||
MIGRATION_31_32,
|
||||
MIGRATION_32_33,
|
||||
MIGRATION_33_34,
|
||||
MIGRATION_34_35,
|
||||
MIGRATION_35_36,
|
||||
MIGRATION_36_37,
|
||||
MIGRATION_37_38,
|
||||
)
|
||||
|
||||
@@ -19,13 +19,16 @@ private const val ALBUM_SONGS_ORDER = " ORDER BY songs.disc, songs.track, songs.
|
||||
|
||||
private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
|
||||
|
||||
private const val ARTIST_SONG_IDS =
|
||||
"(SELECT songId FROM ($CREDITED_SONG_ROWS) WHERE artistId = :artistId)"
|
||||
|
||||
private const val ARTIST_SONGS_SELECT =
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)"
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
|
||||
|
||||
private const val ARTIST_SONGS_ORDER =
|
||||
" ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
||||
" ORDER BY albums.id IS NULL, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
||||
|
||||
private const val SONG_SQL =
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
@@ -82,8 +85,8 @@ interface QueueDao {
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(*) FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)",
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS",
|
||||
)
|
||||
suspend fun artistLength(
|
||||
sourceId: Long,
|
||||
@@ -190,8 +193,8 @@ interface QueueDao {
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND (albums.artistId = :artistId OR songs.artistId = :artistId)" +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS" +
|
||||
ARTIST_SONGS_ORDER,
|
||||
)
|
||||
suspend fun artistSongIds(
|
||||
@@ -211,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(
|
||||
@@ -231,7 +254,7 @@ interface QueueDao {
|
||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||
"ORDER BY songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||
)
|
||||
suspend fun genreSongs(
|
||||
sourceId: Long,
|
||||
@@ -240,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.title 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
|
||||
|
||||
@@ -261,7 +273,7 @@ interface QueueDao {
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL " +
|
||||
"ORDER BY songs.starred DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||
"ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||
)
|
||||
suspend fun starredSongs(
|
||||
sourceId: Long,
|
||||
@@ -269,30 +281,16 @@ interface QueueDao {
|
||||
limit: Int,
|
||||
): List<AlbumSongItem>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
||||
"ORDER BY starred DESC, title 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 " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||
"ORDER BY d.downloadedAt DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
|
||||
)
|
||||
suspend fun downloadedSongs(
|
||||
sourceId: Long,
|
||||
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.title COLLATE NOCASE, songs.id",
|
||||
)
|
||||
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
||||
}
|
||||
|
||||
@@ -29,6 +29,22 @@ interface SourcesDao {
|
||||
@Query("SELECT id FROM sources WHERE isActive = 1 LIMIT 1")
|
||||
suspend fun activeSourceIdOnce(): Long?
|
||||
|
||||
@Query("SELECT creditsStale FROM sources WHERE id = :sourceId")
|
||||
suspend fun creditsStale(sourceId: Long): Boolean?
|
||||
|
||||
@Query("UPDATE sources SET creditsStale = :stale WHERE id = :sourceId AND creditsStale != :stale")
|
||||
suspend fun setCreditsStale(
|
||||
sourceId: Long,
|
||||
stale: Boolean,
|
||||
)
|
||||
|
||||
@Query("UPDATE sources SET name = :name, address = :address WHERE id = :sourceId")
|
||||
suspend fun updateSourceDetails(
|
||||
sourceId: Long,
|
||||
name: String,
|
||||
address: String,
|
||||
)
|
||||
|
||||
@Insert
|
||||
suspend fun insertSource(source: Source): Long
|
||||
|
||||
|
||||
@@ -4,8 +4,11 @@ import androidx.room3.ColumnTypeConverters
|
||||
import androidx.room3.Database
|
||||
import androidx.room3.RoomDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumArtistRef
|
||||
import com.subtracks.data.model.AlbumSearch
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.ArtistRefsConverter
|
||||
import com.subtracks.data.model.ArtistSearch
|
||||
import com.subtracks.data.model.ArtworkSeed
|
||||
import com.subtracks.data.model.Disc
|
||||
@@ -20,10 +23,12 @@ import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
import com.subtracks.data.model.QueueKindConverter
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongArtistRef
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.SongGenre
|
||||
import com.subtracks.data.model.SongSearch
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.StringListConverter
|
||||
import com.subtracks.data.model.SubsonicSource
|
||||
import com.subtracks.data.model.UpNextEntry
|
||||
|
||||
@@ -32,15 +37,18 @@ import com.subtracks.data.model.UpNextEntry
|
||||
Source::class,
|
||||
SubsonicSource::class,
|
||||
Artist::class,
|
||||
ArtistBio::class,
|
||||
ArtistSearch::class,
|
||||
Album::class,
|
||||
AlbumSearch::class,
|
||||
Disc::class,
|
||||
AlbumArtistRef::class,
|
||||
Playlist::class,
|
||||
PlaylistSearch::class,
|
||||
PlaylistSong::class,
|
||||
Song::class,
|
||||
SongGenre::class,
|
||||
SongArtistRef::class,
|
||||
SongSearch::class,
|
||||
QueueEntry::class,
|
||||
UpNextEntry::class,
|
||||
@@ -49,7 +57,7 @@ import com.subtracks.data.model.UpNextEntry
|
||||
SongDownload::class,
|
||||
PendingAction::class,
|
||||
],
|
||||
version = 28,
|
||||
version = 38,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(
|
||||
@@ -57,6 +65,8 @@ import com.subtracks.data.model.UpNextEntry
|
||||
DownloadStatusConverter::class,
|
||||
DownloadErrorConverter::class,
|
||||
PendingActionKindConverter::class,
|
||||
ArtistRefsConverter::class,
|
||||
StringListConverter::class,
|
||||
)
|
||||
abstract class SubtracksDatabase : RoomDatabase() {
|
||||
abstract fun libraryDao(): LibraryDao
|
||||
|
||||
@@ -10,3 +10,8 @@ fun coverArtKey(
|
||||
coverArt: String,
|
||||
thumbnail: Boolean,
|
||||
): String = "$sourceId:$coverArt:$thumbnail"
|
||||
|
||||
fun artistImageKey(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): String = "$sourceId:artist-image:$artistId"
|
||||
|
||||
@@ -58,9 +58,14 @@ data class SongDownload(
|
||||
@ColumnInfo(defaultValue = "0") val downloadedAt: Long = 0,
|
||||
) {
|
||||
val progress: Float?
|
||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
get() = fractionOf(bytes, total)
|
||||
}
|
||||
|
||||
internal fun fractionOf(
|
||||
bytes: Long,
|
||||
total: Long,
|
||||
): Float? = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
|
||||
enum class DownloadList { Album, Playlist, Artist }
|
||||
|
||||
enum class BulkDownloadAction { Download, Cancel, Delete }
|
||||
@@ -94,19 +99,24 @@ data class DownloadedSong(
|
||||
val size: Long = 0,
|
||||
val bytes: Long = 0,
|
||||
val total: Long = 0,
|
||||
val artistSort: String = artistName.orEmpty(),
|
||||
val albumSort: String = albumName.orEmpty(),
|
||||
val songSort: String = title,
|
||||
) {
|
||||
val progress: Float?
|
||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
get() = fractionOf(bytes, total)
|
||||
}
|
||||
|
||||
data class DownloadArtwork(
|
||||
val sourceId: Long,
|
||||
val albumId: String?,
|
||||
val artistId: String?,
|
||||
val albumArtistId: String?,
|
||||
val albumRow: String?,
|
||||
val artistRow: String?,
|
||||
val albumCoverArt: String?,
|
||||
val artistCoverArt: String?,
|
||||
val albumArtistCoverArt: String?,
|
||||
) {
|
||||
val hasMissingLibraryRow: Boolean
|
||||
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
||||
|
||||
@@ -18,6 +18,7 @@ data class Source(
|
||||
val address: String,
|
||||
val isActive: Boolean = false,
|
||||
val createdAt: Long,
|
||||
@ColumnInfo(defaultValue = "1") val creditsStale: Boolean = true,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
@@ -39,6 +40,11 @@ data class SubsonicSource(
|
||||
val useTokenAuth: Boolean = true,
|
||||
)
|
||||
|
||||
data class ArtistRef(
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "artists",
|
||||
primaryKeys = ["sourceId", "id"],
|
||||
@@ -52,25 +58,26 @@ data class SubsonicSource(
|
||||
],
|
||||
indices = [
|
||||
Index("sourceId"),
|
||||
Index(name = "index_artists_name", value = ["sourceId", "name", "id"]),
|
||||
Index(name = "index_artists_name", value = ["sourceId", "sortName", "id"]),
|
||||
Index(name = "index_artists_name_plain", value = ["sourceId", "name", "id"]),
|
||||
Index(
|
||||
name = "index_artists_albumCount",
|
||||
value = ["sourceId", "albumCount", "name", "id"],
|
||||
value = ["sourceId", "albumCount", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_artists_starred",
|
||||
value = ["sourceId", "starred", "name", "id"],
|
||||
value = ["sourceId", "starred", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_artists_frequent",
|
||||
value = ["sourceId", "playCount", "name", "id"],
|
||||
value = ["sourceId", "playCount", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_artists_recent",
|
||||
value = ["sourceId", "played", "name", "id"],
|
||||
value = ["sourceId", "played", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
],
|
||||
@@ -82,8 +89,32 @@ data class Artist(
|
||||
val albumCount: Long,
|
||||
val starred: Long?,
|
||||
val coverArt: String? = null,
|
||||
val artistImageUrl: String? = null,
|
||||
@ColumnInfo(defaultValue = "''") val coverArts: List<String> = emptyList(),
|
||||
@ColumnInfo(defaultValue = "''") val downloadedCoverArts: List<String> = emptyList(),
|
||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||
val played: Long? = null,
|
||||
@ColumnInfo(defaultValue = "0") val songCount: Long = 0,
|
||||
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
|
||||
@ColumnInfo(defaultValue = "1") val credited: Boolean = true,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "artist_bios",
|
||||
primaryKeys = ["sourceId", "artistId"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Source::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["sourceId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
)
|
||||
data class ArtistBio(
|
||||
val sourceId: Long,
|
||||
val artistId: String,
|
||||
val biography: String?,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
@@ -100,31 +131,33 @@ data class Artist(
|
||||
indices = [
|
||||
Index("sourceId"),
|
||||
Index("sourceId", "artistId"),
|
||||
Index(name = "index_albums_name", value = ["sourceId", "name", "id"]),
|
||||
Index(name = "index_albums_artist", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
||||
Index(name = "index_albums_name", value = ["sourceId", "sortName", "id"]),
|
||||
Index(name = "index_albums_name_plain", value = ["sourceId", "name", "id"]),
|
||||
Index(name = "index_albums_artist", value = ["sourceId", "albumArtistSort", "year", "sortName", "id"]),
|
||||
Index(name = "index_albums_artist_plain", value = ["sourceId", "albumArtist", "year", "name", "id"]),
|
||||
Index(
|
||||
name = "index_albums_year",
|
||||
value = ["sourceId", "year", "name", "id"],
|
||||
value = ["sourceId", "year", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_albums_added",
|
||||
value = ["sourceId", "created", "name", "id"],
|
||||
value = ["sourceId", "created", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_albums_starred",
|
||||
value = ["sourceId", "starred", "name", "id"],
|
||||
value = ["sourceId", "starred", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_albums_frequent",
|
||||
value = ["sourceId", "playCount", "name", "id"],
|
||||
value = ["sourceId", "playCount", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_albums_recent",
|
||||
value = ["sourceId", "played", "name", "id"],
|
||||
value = ["sourceId", "played", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
],
|
||||
@@ -144,7 +177,26 @@ data class Album(
|
||||
@Ignore val discTitles: Map<Long, String> = emptyMap(),
|
||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||
val played: Long? = null,
|
||||
)
|
||||
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
|
||||
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = name,
|
||||
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val albumArtistSort: String = albumArtist.orEmpty(),
|
||||
) {
|
||||
val artistRefs: List<ArtistRef>
|
||||
get() =
|
||||
artists.ifEmpty {
|
||||
artistId
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { listOf(ArtistRef(it, albumArtist.orEmpty())) }
|
||||
.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
data class ArtistAlbums(
|
||||
val albumCredits: List<Album> = emptyList(),
|
||||
val appearances: List<Album> = emptyList(),
|
||||
) {
|
||||
val count: Int get() = albumCredits.size + appearances.size
|
||||
}
|
||||
|
||||
@Entity(
|
||||
tableName = "discs",
|
||||
@@ -202,6 +254,8 @@ data class Playlist(
|
||||
val created: Long,
|
||||
@ColumnInfo(defaultValue = "0") val changed: Long = 0,
|
||||
@ColumnInfo(defaultValue = "0") val duration: Long = 0,
|
||||
val owner: String? = null,
|
||||
@ColumnInfo(defaultValue = "0") val shared: Boolean = false,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
@@ -243,8 +297,8 @@ data class PlaylistSong(
|
||||
),
|
||||
Index(
|
||||
name = "index_songs_starred",
|
||||
value = ["sourceId", "starred", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC],
|
||||
value = ["sourceId", "starred", "sortName", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -269,7 +323,19 @@ data class Song(
|
||||
val trackPeak: Float? = null,
|
||||
val albumPeak: Float? = null,
|
||||
@Ignore val genres: List<String> = emptyList(),
|
||||
)
|
||||
@ColumnInfo(defaultValue = "''") val artists: List<ArtistRef> = emptyList(),
|
||||
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val sortName: String = title,
|
||||
@ColumnInfo(collate = ColumnInfo.NOCASE, defaultValue = "''") val artistSort: String = artist.orEmpty(),
|
||||
) {
|
||||
val artistRefs: List<ArtistRef>
|
||||
get() =
|
||||
artists.ifEmpty {
|
||||
artistId
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { listOf(ArtistRef(it, artist.orEmpty())) }
|
||||
.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Entity(
|
||||
tableName = "song_genres",
|
||||
@@ -291,6 +357,48 @@ data class SongGenre(
|
||||
val genre: String,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "album_artists",
|
||||
primaryKeys = ["sourceId", "albumId", "position"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Album::class,
|
||||
parentColumns = ["sourceId", "id"],
|
||||
childColumns = ["sourceId", "albumId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("sourceId", "artistId")],
|
||||
)
|
||||
data class AlbumArtistRef(
|
||||
val sourceId: Long,
|
||||
val albumId: String,
|
||||
val position: Long,
|
||||
val artistId: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "song_artists",
|
||||
primaryKeys = ["sourceId", "songId", "position"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Song::class,
|
||||
parentColumns = ["sourceId", "id"],
|
||||
childColumns = ["sourceId", "songId"],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
),
|
||||
],
|
||||
indices = [Index("sourceId", "artistId")],
|
||||
)
|
||||
data class SongArtistRef(
|
||||
val sourceId: Long,
|
||||
val songId: String,
|
||||
val position: Long,
|
||||
val artistId: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
enum class PendingActionKind { Scrobble, Star, Unstar }
|
||||
|
||||
@Entity(
|
||||
@@ -438,6 +546,8 @@ data class AlbumSearch(
|
||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||
val name: String,
|
||||
val albumArtist: String?,
|
||||
val sortName: String,
|
||||
val albumArtistSort: String,
|
||||
)
|
||||
|
||||
@Entity(tableName = "artist_search")
|
||||
@@ -445,6 +555,7 @@ data class AlbumSearch(
|
||||
data class ArtistSearch(
|
||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||
val name: String,
|
||||
val sortName: String,
|
||||
)
|
||||
|
||||
@Entity(tableName = "playlist_search")
|
||||
@@ -460,6 +571,8 @@ data class SongSearch(
|
||||
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Long = 0,
|
||||
val title: String,
|
||||
val artist: String?,
|
||||
val sortName: String,
|
||||
val artistSort: String,
|
||||
)
|
||||
|
||||
class QueueKindConverter {
|
||||
@@ -478,3 +591,53 @@ class PendingActionKindConverter {
|
||||
fun toPendingActionKind(value: String): PendingActionKind =
|
||||
PendingActionKind.entries.firstOrNull { it.name == value } ?: PendingActionKind.Scrobble
|
||||
}
|
||||
|
||||
class ArtistRefsConverter {
|
||||
@ColumnTypeConverter
|
||||
fun fromArtistRefs(refs: List<ArtistRef>): String = ArtistRefsCodec.encode(refs)
|
||||
|
||||
@ColumnTypeConverter
|
||||
fun toArtistRefs(value: String): List<ArtistRef> = ArtistRefsCodec.decode(value)
|
||||
}
|
||||
|
||||
class StringListConverter {
|
||||
@ColumnTypeConverter
|
||||
fun fromList(values: List<String>): String = values.joinToString(LIST_SEPARATOR)
|
||||
|
||||
@ColumnTypeConverter
|
||||
fun toList(value: String): List<String> = if (value.isEmpty()) emptyList() else value.split(LIST_SEPARATOR)
|
||||
}
|
||||
|
||||
private const val LIST_SEPARATOR = "\u001F"
|
||||
|
||||
object ArtistRefsCodec {
|
||||
fun encode(refs: List<ArtistRef>): String =
|
||||
buildString {
|
||||
refs.forEach { ref ->
|
||||
append(ref.id.length).append(':').append(ref.id)
|
||||
append(ref.name.length).append(':').append(ref.name)
|
||||
}
|
||||
}
|
||||
|
||||
fun decode(value: String): List<ArtistRef> {
|
||||
val refs = mutableListOf<ArtistRef>()
|
||||
var index = 0
|
||||
while (index < value.length) {
|
||||
val idLengthEnd = value.indexOf(':', index)
|
||||
if (idLengthEnd < 0) return refs
|
||||
val idLength = value.substring(index, idLengthEnd).toIntOrNull() ?: return refs
|
||||
val idStart = idLengthEnd + 1
|
||||
val idEnd = idStart + idLength
|
||||
if (idLength < 0 || idEnd > value.length) return refs
|
||||
val nameLengthEnd = value.indexOf(':', idEnd)
|
||||
if (nameLengthEnd < 0) return refs
|
||||
val nameLength = value.substring(idEnd, nameLengthEnd).toIntOrNull() ?: return refs
|
||||
val nameStart = nameLengthEnd + 1
|
||||
val nameEnd = nameStart + nameLength
|
||||
if (nameLength < 0 || nameEnd > value.length) return refs
|
||||
refs += ArtistRef(value.substring(idStart, idEnd), value.substring(nameStart, nameEnd))
|
||||
index = nameEnd
|
||||
}
|
||||
return refs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class SortNameMode { SortName, Name, NonLatin }
|
||||
|
||||
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
|
||||
|
||||
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
|
||||
@@ -57,7 +59,7 @@ const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
|
||||
|
||||
enum class SyncMode { Off, WifiOnly, Always }
|
||||
|
||||
enum class ReplayGainMode { Off, Track, Album }
|
||||
enum class ReplayGainMode { Off, Track, Album, Auto }
|
||||
|
||||
data class ArtworkSeedValue(
|
||||
val cacheKey: String,
|
||||
@@ -154,7 +156,7 @@ class UserPreferences(
|
||||
|
||||
fun replayGainMode(): Flow<ReplayGainMode> =
|
||||
store.data.map { prefs ->
|
||||
prefs[replayGainModeKey]?.let { stored -> ReplayGainMode.entries.firstOrNull { it.name == stored } } ?: ReplayGainMode.Track
|
||||
prefs[replayGainModeKey]?.let { stored -> ReplayGainMode.entries.firstOrNull { it.name == stored } } ?: ReplayGainMode.Auto
|
||||
}
|
||||
|
||||
suspend fun setReplayGainMode(mode: ReplayGainMode) {
|
||||
@@ -174,6 +176,25 @@ class UserPreferences(
|
||||
|
||||
private val replayGainPreampKey = floatPreferencesKey("replay_gain_preamp_db")
|
||||
|
||||
fun keepScreenOn(): Flow<Boolean> = store.data.map { prefs -> prefs[keepScreenOnKey] ?: false }
|
||||
|
||||
suspend fun setKeepScreenOn(enabled: Boolean) {
|
||||
store.edit { prefs -> prefs[keepScreenOnKey] = enabled }
|
||||
}
|
||||
|
||||
private val keepScreenOnKey = booleanPreferencesKey("keep_screen_on")
|
||||
|
||||
fun sortNameMode(): Flow<SortNameMode> =
|
||||
store.data.map { prefs ->
|
||||
prefs[sortNameModeKey]?.let { stored -> SortNameMode.entries.firstOrNull { it.name == stored } } ?: SortNameMode.Name
|
||||
}
|
||||
|
||||
suspend fun setSortNameMode(mode: SortNameMode) {
|
||||
store.edit { prefs -> prefs[sortNameModeKey] = mode.name }
|
||||
}
|
||||
|
||||
private val sortNameModeKey = stringPreferencesKey("sort_name_mode")
|
||||
|
||||
fun offlineMode(): Flow<Boolean> = store.data.map { prefs -> prefs[offlineModeKey] ?: false }
|
||||
|
||||
suspend fun setOfflineMode(enabled: Boolean) {
|
||||
|
||||
@@ -11,6 +11,8 @@ import com.subtracks.data.download.EngineDownload
|
||||
import com.subtracks.data.download.EngineRequest
|
||||
import com.subtracks.data.download.EngineStatus
|
||||
import com.subtracks.data.download.looksLikeAudio
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.DownloadError
|
||||
import com.subtracks.data.model.DownloadList
|
||||
@@ -18,6 +20,7 @@ import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.DownloadedSong
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -43,6 +46,7 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class DownloadRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
@@ -63,6 +67,8 @@ class DownloadRepository(
|
||||
private var started = false
|
||||
|
||||
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
||||
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
|
||||
private val failedArtistImages = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
fun start() {
|
||||
if (started) return
|
||||
@@ -347,6 +353,9 @@ class DownloadRepository(
|
||||
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
||||
group.forEach { file(sourceId, it.songId).delete() }
|
||||
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
|
||||
if (group.any { it.status == DownloadStatus.Completed }) {
|
||||
refreshDownloadedArtistArt(sourceId, group.map { it.songId })
|
||||
}
|
||||
}
|
||||
sweep(db.downloadDao().all())
|
||||
}
|
||||
@@ -369,6 +378,7 @@ class DownloadRepository(
|
||||
.mapNotNull { it.engineId }
|
||||
.let(engine::cancel)
|
||||
db.downloadDao().deleteSource(sourceId)
|
||||
db.libraryDao().clearDownloadedArtistArt(sourceId)
|
||||
dir(sourceId).deleteRecursively()
|
||||
}
|
||||
}
|
||||
@@ -406,14 +416,18 @@ class DownloadRepository(
|
||||
private suspend fun fetchArtwork(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
artistIds: MutableSet<String>,
|
||||
) {
|
||||
val song = db.libraryDao().songOnce(sourceId, songId)
|
||||
val album = song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it) }
|
||||
val songArtist = song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
|
||||
val albumArtist = album?.artistId?.let { db.libraryDao().artistOnce(sourceId, it) }
|
||||
listOfNotNull(song?.artistId, album?.artistId).forEach(artistIds::add)
|
||||
listOfNotNull(songArtist, albumArtist).forEach { storeArtistImage(sourceId, it) }
|
||||
val coverArt =
|
||||
(
|
||||
listOfNotNull(
|
||||
song?.albumId?.let { db.libraryDao().albumOnce(sourceId, it)?.coverArt },
|
||||
song?.artistId?.let { db.libraryDao().artistOnce(sourceId, it)?.coverArt },
|
||||
) + db.downloadDao().playlistCoversForSong(sourceId, songId)
|
||||
listOfNotNull(album?.coverArt) +
|
||||
db.downloadDao().playlistCoversForSong(sourceId, songId)
|
||||
).distinct()
|
||||
coverArt.forEach { cover ->
|
||||
storeArtwork(sourceId, cover, thumbnail = false)
|
||||
@@ -421,6 +435,67 @@ class DownloadRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeArtistImage(
|
||||
sourceId: Long,
|
||||
artist: Artist,
|
||||
) {
|
||||
val imageUrl = artist.artistImageUrl
|
||||
if (!imageUrl.isNullOrBlank()) {
|
||||
val cacheKey = artistImageKey(sourceId, artist.id)
|
||||
if (artworkStore.file(sourceId, cacheKey).exists()) return
|
||||
val attemptKey = "$sourceId:${artist.id}:$imageUrl"
|
||||
if (attemptKey !in failedArtistImages) {
|
||||
val bytes =
|
||||
try {
|
||||
artworkFetcher.fetch(imageUrl)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (bytes != null && bytes.isNotEmpty()) {
|
||||
artworkStore.write(sourceId, cacheKey, bytes)
|
||||
return
|
||||
}
|
||||
failedArtistImages += attemptKey
|
||||
}
|
||||
}
|
||||
artist.coverArt?.let {
|
||||
storeArtwork(sourceId, it, thumbnail = false)
|
||||
storeArtwork(sourceId, it, thumbnail = true)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchArtistBio(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
) {
|
||||
if (db.libraryDao().artistBioOnce(sourceId, artistId) != null) return
|
||||
val key = sourceId to artistId
|
||||
if (!attemptedBios.add(key)) return
|
||||
try {
|
||||
val biography = sourceRepository.artistBiography(sourceId, artistId)
|
||||
db.libraryDao().upsertArtistBio(
|
||||
ArtistBio(sourceId, artistId, normalizeBiography(biography)),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
} finally {
|
||||
attemptedBios.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchArtistBiosSafely(
|
||||
sourceId: Long,
|
||||
artistIds: Set<String>,
|
||||
) {
|
||||
if (artistIds.isEmpty()) return
|
||||
withTimeoutOrNull(BIO_TIMEOUT_MS) {
|
||||
artistIds.forEach { fetchArtistBio(sourceId, it) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeArtwork(
|
||||
sourceId: Long,
|
||||
coverArt: String,
|
||||
@@ -455,11 +530,15 @@ class DownloadRepository(
|
||||
.mapValues { (sourceId, rows) ->
|
||||
if (rows.any { it.hasMissingLibraryRow }) return@mapValues null
|
||||
val covers =
|
||||
rows.flatMap { row -> listOfNotNull(row.albumCoverArt, row.artistCoverArt) } +
|
||||
playlistCovers[sourceId].orEmpty()
|
||||
covers
|
||||
.flatMap { cover -> listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) } }
|
||||
.toSet()
|
||||
rows.flatMap { row ->
|
||||
listOfNotNull(row.albumCoverArt, row.artistCoverArt, row.albumArtistCoverArt)
|
||||
} + playlistCovers[sourceId].orEmpty()
|
||||
val artistImages = rows.flatMap { row -> listOfNotNull(row.artistId, row.albumArtistId) }
|
||||
(
|
||||
covers.flatMap { cover ->
|
||||
listOf(false, true).map { thumbnail -> coverArtKey(sourceId, cover, thumbnail) }
|
||||
} + artistImages.map { artistImageKey(sourceId, it) }
|
||||
).toSet()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +588,7 @@ class DownloadRepository(
|
||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||
file(sourceId, songId).delete()
|
||||
db.downloadDao().delete(sourceId, songId)
|
||||
if (row.status == DownloadStatus.Completed) refreshDownloadedArtistArt(sourceId, listOf(songId))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -537,11 +617,12 @@ class DownloadRepository(
|
||||
sourceId: Long,
|
||||
songIds: List<String>,
|
||||
) {
|
||||
val artistIds = mutableSetOf<String>()
|
||||
try {
|
||||
withTimeoutOrNull(ART_TIMEOUT_MS) {
|
||||
songIds.forEach { songId ->
|
||||
// A cancel between the queue and this pass takes the row away; nothing to warm.
|
||||
if (db.downloadDao().find(sourceId, songId) != null) fetchArtwork(sourceId, songId)
|
||||
if (db.downloadDao().find(sourceId, songId) != null) fetchArtwork(sourceId, songId, artistIds)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
@@ -549,6 +630,7 @@ class DownloadRepository(
|
||||
} catch (_: Exception) {
|
||||
// Artwork is best effort and must never disturb a download that is already running.
|
||||
}
|
||||
fetchArtistBiosSafely(sourceId, artistIds)
|
||||
}
|
||||
|
||||
private suspend fun apply(
|
||||
@@ -587,6 +669,11 @@ class DownloadRepository(
|
||||
},
|
||||
)
|
||||
if (updated != row) db.downloadDao().upsert(updated)
|
||||
if (updated.status != row.status &&
|
||||
(updated.status == DownloadStatus.Completed || row.status == DownloadStatus.Completed)
|
||||
) {
|
||||
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
|
||||
}
|
||||
if (updated.status != row.status) {
|
||||
when (updated.status) {
|
||||
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
|
||||
@@ -604,9 +691,22 @@ class DownloadRepository(
|
||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||
file(row.sourceId, row.songId).delete()
|
||||
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
||||
if (row.status == DownloadStatus.Completed) {
|
||||
refreshDownloadedArtistArt(row.sourceId, listOf(row.songId))
|
||||
}
|
||||
Log.w("download", "failed ${row.songId}: $error")
|
||||
}
|
||||
|
||||
private suspend fun refreshDownloadedArtistArt(
|
||||
sourceId: Long,
|
||||
songIds: Collection<String>,
|
||||
) {
|
||||
songIds.chunked(500).forEach { songs ->
|
||||
val artistIds = db.libraryDao().creditedArtistIdsForSongs(sourceId, songs).distinct()
|
||||
artistIds.chunked(500).forEach { db.libraryDao().recomputeDownloadedArtistArt(sourceId, it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensurePolling() {
|
||||
if (pollJob?.isActive == true) return
|
||||
pollJob =
|
||||
@@ -673,5 +773,6 @@ class DownloadRepository(
|
||||
const val IN_FLIGHT_LIMIT = 8
|
||||
const val DELETE_CHUNK = 900
|
||||
const val ART_TIMEOUT_MS = 60_000L
|
||||
const val BIO_TIMEOUT_MS = 30_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,15 @@ import androidx.paging.PagingSource
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.albumsDefaultQuery
|
||||
import com.subtracks.data.db.artistsDefaultQuery
|
||||
import com.subtracks.data.db.playlistsDefaultQuery
|
||||
import com.subtracks.data.db.albumsPageQuery
|
||||
import com.subtracks.data.db.artistsPageQuery
|
||||
import com.subtracks.data.db.playlistsPageQuery
|
||||
import com.subtracks.data.db.rediscoverAlbumsQuery
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistAlbums
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSongItem
|
||||
@@ -22,25 +24,32 @@ import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class LibraryRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val serverActions: ServerActionSink,
|
||||
private val userPreferences: UserPreferences,
|
||||
private val showMessage: (UiMessage) -> Unit = {},
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) {
|
||||
@@ -107,12 +116,18 @@ class LibraryRepository(
|
||||
fun searchAlbums(
|
||||
sourceId: Long,
|
||||
query: String,
|
||||
): Flow<List<Album>> = db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||
): Flow<List<Album>> =
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
db.libraryDao().searchAlbums(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||
}
|
||||
|
||||
fun searchArtists(
|
||||
sourceId: Long,
|
||||
query: String,
|
||||
): Flow<List<Artist>> = db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||
): Flow<List<Artist>> =
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
db.libraryDao().searchArtists(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||
}
|
||||
|
||||
fun searchPlaylists(
|
||||
sourceId: Long,
|
||||
@@ -122,7 +137,10 @@ class LibraryRepository(
|
||||
fun searchSongs(
|
||||
sourceId: Long,
|
||||
query: String,
|
||||
): Flow<List<AlbumSongItem>> = db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT)
|
||||
): Flow<List<AlbumSongItem>> =
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
db.libraryDao().searchSongs(sourceId, query, SEARCH_RESULT_LIMIT, mode.name)
|
||||
}
|
||||
|
||||
fun downloadedAlbumsPage(sourceId: Long): Flow<PagingData<Album>> = pager(40) { db.libraryDao().downloadedAlbums(sourceId) }
|
||||
|
||||
@@ -136,9 +154,9 @@ class LibraryRepository(
|
||||
suspend fun genreSongOrdinal(
|
||||
sourceId: Long,
|
||||
genre: String,
|
||||
title: String,
|
||||
sortName: String,
|
||||
id: String,
|
||||
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, title, id)
|
||||
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, sortName, id)
|
||||
|
||||
suspend fun downloadedSongOrdinal(
|
||||
sourceId: Long,
|
||||
@@ -174,69 +192,20 @@ class LibraryRepository(
|
||||
search: String,
|
||||
downloaded: Boolean,
|
||||
): Flow<PagingData<Album>> =
|
||||
pager(40, enablePlaceholders = true) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
AlbumSort.Name -> {
|
||||
if (descending) {
|
||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Artist -> {
|
||||
if (descending) {
|
||||
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Year -> {
|
||||
if (descending) {
|
||||
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Added -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
pager(40, enablePlaceholders = true) {
|
||||
val dao = db.libraryDao()
|
||||
val query =
|
||||
albumsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starred.ordinal,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = downloaded,
|
||||
sortNameMode = mode,
|
||||
)
|
||||
if (downloaded) dao.downloadedAlbumsPaging(query) else dao.albumsPaging(query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,53 +217,20 @@ class LibraryRepository(
|
||||
search: String,
|
||||
downloaded: Boolean,
|
||||
): Flow<PagingData<Artist>> =
|
||||
pager(60) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
ArtistSort.Name -> {
|
||||
if (descending) {
|
||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.AlbumCount -> {
|
||||
if (descending) {
|
||||
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
pager(60) {
|
||||
val dao = db.libraryDao()
|
||||
val query =
|
||||
artistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starred.ordinal,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = downloaded,
|
||||
sortNameMode = mode,
|
||||
)
|
||||
if (downloaded) dao.downloadedArtistsPaging(query) else dao.artistsPaging(query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,36 +243,15 @@ class LibraryRepository(
|
||||
): Flow<PagingData<Playlist>> =
|
||||
pager(40) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.playlistsPaging(playlistsDefaultQuery(sourceId, search, sort, descending))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
PlaylistSort.Name -> {
|
||||
if (descending) {
|
||||
dao.playlistsByNameReversed(sourceId, search, downloadedFilter)
|
||||
} else {
|
||||
dao.playlistsByName(sourceId, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
PlaylistSort.Added -> {
|
||||
if (descending) {
|
||||
dao.playlistsByAddedReversed(sourceId, search, downloadedFilter)
|
||||
} else {
|
||||
dao.playlistsByAdded(sourceId, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
PlaylistSort.Updated -> {
|
||||
if (descending) {
|
||||
dao.playlistsByUpdatedReversed(sourceId, search, downloadedFilter)
|
||||
} else {
|
||||
dao.playlistsByUpdated(sourceId, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val query =
|
||||
playlistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = downloaded,
|
||||
)
|
||||
if (downloaded) dao.downloadedPlaylistsPaging(query) else dao.playlistsPaging(query)
|
||||
}
|
||||
|
||||
fun album(
|
||||
@@ -354,6 +269,36 @@ class LibraryRepository(
|
||||
artistId: String,
|
||||
): Flow<String?> = db.libraryDao().artistName(sourceId, artistId)
|
||||
|
||||
fun artistBio(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<ArtistBio?> =
|
||||
db
|
||||
.libraryDao()
|
||||
.artistBio(sourceId, artistId)
|
||||
.map { bio -> bio?.copy(biography = normalizeBiography(bio.biography)) }
|
||||
|
||||
suspend fun refreshArtistBio(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Result<Unit> =
|
||||
try {
|
||||
val fetched = normalizeBiography(sourceRepository.artistBiography(sourceId, artistId))
|
||||
val current = db.libraryDao().artistBioOnce(sourceId, artistId)
|
||||
// An empty response can mean the metadata provider is temporarily down, so keep the
|
||||
// cached biography instead of erasing it.
|
||||
val biography = fetched ?: current?.biography
|
||||
if (current == null || current.biography != biography) {
|
||||
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, artistId, biography))
|
||||
}
|
||||
Result.success(Unit)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
Log.w("artist", "failed to refresh bio for $artistId", failure)
|
||||
Result.failure(failure)
|
||||
}
|
||||
|
||||
fun song(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
@@ -376,12 +321,24 @@ class LibraryRepository(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
downloaded: Boolean = false,
|
||||
): Flow<List<Album>> =
|
||||
if (downloaded) {
|
||||
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
|
||||
} else {
|
||||
db.libraryDao().albumsForArtist(sourceId, artistId)
|
||||
): Flow<ArtistAlbums> {
|
||||
val albums =
|
||||
if (downloaded) {
|
||||
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
|
||||
} else {
|
||||
db.libraryDao().albumsForArtist(sourceId, artistId)
|
||||
}
|
||||
return combine(albums, db.libraryDao().albumCreditAlbumIds(sourceId, artistId)) { list, creditIds ->
|
||||
val credits = creditIds.toHashSet()
|
||||
val (albumCredits, appearances) = list.partition { it.id in credits }
|
||||
ArtistAlbums(albumCredits = albumCredits, appearances = appearances)
|
||||
}
|
||||
}
|
||||
|
||||
fun artistCoverArts(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<String>> = db.libraryDao().artistCoverArts(sourceId, artistId)
|
||||
|
||||
fun albumDiscs(
|
||||
sourceId: Long,
|
||||
@@ -403,10 +360,7 @@ class LibraryRepository(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
songId: String,
|
||||
): Long {
|
||||
val index = db.queueDao().albumSongIds(sourceId, albumId).indexOf(songId)
|
||||
return if (index < 0) 0L else index.toLong()
|
||||
}
|
||||
): Long = db.queueDao().albumRank(sourceId, albumId, songId) ?: 0L
|
||||
|
||||
fun playlist(
|
||||
sourceId: Long,
|
||||
@@ -520,4 +474,12 @@ const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000
|
||||
|
||||
const val SEARCH_RESULT_LIMIT = 50
|
||||
|
||||
internal fun normalizeBiography(value: String?): String? =
|
||||
value
|
||||
?.replace("\r\n", "\n")
|
||||
?.replace('\r', '\n')
|
||||
?.replace(Regex(" {3,}"), "\n\n")
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
|
||||
|
||||
@@ -164,6 +164,8 @@ data class QueueSnapshot(
|
||||
}
|
||||
|
||||
fun locateContext(contextPlay: Long): Pair<QueueEntry, Long>? = locateIn(entries, flatContext(contextPlay) ?: return null)
|
||||
|
||||
fun isAlbumContext(position: Long): Boolean = locate(position)?.first?.kind == QueueKind.Album
|
||||
}
|
||||
|
||||
private fun entryIndexAfter(
|
||||
@@ -207,6 +209,202 @@ data class Reanchored(
|
||||
val changed: Boolean,
|
||||
)
|
||||
|
||||
private interface QueueKindResolver {
|
||||
fun supportsKeyset(key: OrderKey): Boolean = false
|
||||
|
||||
suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
): Long
|
||||
|
||||
suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
): List<SongItem>
|
||||
|
||||
suspend fun rowsFrom(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem>? = null
|
||||
|
||||
suspend fun rowsBefore(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem>? = null
|
||||
|
||||
suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
): Long? = null
|
||||
}
|
||||
|
||||
private val queueKindResolvers =
|
||||
mapOf<QueueKind, QueueKindResolver>(
|
||||
QueueKind.Song to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.songLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.song(entry.sourceId, entry.refId, offset, limit)
|
||||
|
||||
override suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
) = if (entry.refId == songId && length(dao, entry) > 0) 0L else null
|
||||
},
|
||||
QueueKind.Playlist to
|
||||
object : QueueKindResolver {
|
||||
override fun supportsKeyset(key: OrderKey) = key.position != null
|
||||
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.playlistLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
|
||||
override suspend fun rowsFrom(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
|
||||
override suspend fun rowsBefore(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
|
||||
override suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
) = dao.playlistRank(entry.sourceId, entry.refId, songId)
|
||||
},
|
||||
QueueKind.Album to
|
||||
object : QueueKindResolver {
|
||||
override fun supportsKeyset(key: OrderKey) = key.disc != null && key.track != null
|
||||
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.albumLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
|
||||
override suspend fun rowsFrom(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
|
||||
override suspend fun rowsBefore(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
|
||||
override suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
) = dao.albumRank(entry.sourceId, entry.refId, songId)
|
||||
},
|
||||
QueueKind.Artist to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.artistLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
},
|
||||
QueueKind.Genre to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.genreLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
},
|
||||
QueueKind.Downloaded to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.downloadedLength(entry.sourceId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.downloadedSongs(entry.sourceId, offset, limit)
|
||||
},
|
||||
QueueKind.Starred to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.starredLength(entry.sourceId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.starredSongs(entry.sourceId, offset, limit)
|
||||
},
|
||||
)
|
||||
|
||||
private fun resolverFor(kind: QueueKind): QueueKindResolver = queueKindResolvers.getValue(kind)
|
||||
|
||||
class QueueRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
) {
|
||||
@@ -214,12 +412,6 @@ class QueueRepository(
|
||||
|
||||
private val queueVersion = AtomicLong()
|
||||
|
||||
@Volatile
|
||||
private var cachedFlatIds: List<String>? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedFlatVersion = -1L
|
||||
|
||||
@Volatile
|
||||
private var shuffleRemoved: ShuffleState? = null
|
||||
|
||||
@@ -233,6 +425,9 @@ class QueueRepository(
|
||||
internal var offsetSeeks = 0L
|
||||
private set
|
||||
|
||||
internal var resolvedRowCount = 0L
|
||||
private set
|
||||
|
||||
private val cursorMutex = Mutex()
|
||||
|
||||
fun playlistEntry(
|
||||
@@ -295,8 +490,8 @@ class QueueRepository(
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
// An explicit song entry in the middle of a queue is a reordered context track, so
|
||||
// label it by its album rather than losing the context name.
|
||||
// A song entry can be a single or a track reordered out of a playlist, artist or
|
||||
// genre, so label it by its album rather than losing the context name.
|
||||
QueueKind.Song -> {
|
||||
library
|
||||
.song(sourceId, refId)
|
||||
@@ -386,7 +581,6 @@ class QueueRepository(
|
||||
}
|
||||
|
||||
fun invalidateLibraryCache() {
|
||||
cachedFlatIds = null
|
||||
lengthCache.clear()
|
||||
keyCache.clear()
|
||||
shuffleRemoved = null
|
||||
@@ -407,8 +601,8 @@ class QueueRepository(
|
||||
snapshot: QueueSnapshot,
|
||||
songId: String,
|
||||
): Long? {
|
||||
val upIndex = idsFor(snapshot.upNext).indexOf(songId)
|
||||
if (upIndex >= 0) return snapshot.anchorPlay + 1 + upIndex
|
||||
val upIndex = flatIndexOf(snapshot.upNext, songId)
|
||||
if (upIndex != null) return snapshot.anchorPlay + 1 + upIndex
|
||||
return combinedContextIndexOf(snapshot, songId)
|
||||
}
|
||||
|
||||
@@ -416,9 +610,8 @@ class QueueRepository(
|
||||
snapshot: QueueSnapshot,
|
||||
songId: String,
|
||||
): Long? {
|
||||
val flat = flatIds(snapshot).indexOf(songId)
|
||||
if (flat < 0) return null
|
||||
return snapshot.playContext(flat.toLong())?.let { snapshot.combined(it) }
|
||||
val flat = flatIndexOf(snapshot.entries, songId) ?: return null
|
||||
return snapshot.playContext(flat)?.let { snapshot.combined(it) }
|
||||
}
|
||||
|
||||
suspend fun range(
|
||||
@@ -581,9 +774,9 @@ class QueueRepository(
|
||||
val toUpNext = snapshot.isUpNext(to)
|
||||
if (fromUpNext && toUpNext) {
|
||||
val song = itemAt(snapshot, from) ?: return false
|
||||
val sourceId = snapshot.locate(from)?.first?.sourceId ?: return false
|
||||
val located = snapshot.locate(from) ?: return false
|
||||
val removed = removeEntry(snapshot.upNext, snapshot.upNextIndex(from))
|
||||
val moved = ResolvedQueueEntry(songEntry(sourceId, song.song.id), 1L)
|
||||
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
|
||||
writeUpNext(compact(insertEntry(removed, snapshot.upNextIndex(to), moved)).map { it.entry })
|
||||
return true
|
||||
}
|
||||
@@ -597,7 +790,7 @@ class QueueRepository(
|
||||
val located = snapshot.locateContext(playFrom) ?: return false
|
||||
val song = itemAt(snapshot, from) ?: return false
|
||||
val removed = removeEntry(snapshot.entries, playFrom)
|
||||
val moved = ResolvedQueueEntry(songEntry(located.first.sourceId, song.song.id), 1L)
|
||||
val moved = ResolvedQueueEntry(movedEntry(located.first, song.song.id, located.second), 1L)
|
||||
val insertAt = playTo.coerceIn(0, removed.sumOf { it.length })
|
||||
write(compact(insertEntry(removed, insertAt, moved)).map { it.entry })
|
||||
adjustAnchorOnMove(playFrom, insertAt)
|
||||
@@ -631,7 +824,6 @@ class QueueRepository(
|
||||
}
|
||||
}
|
||||
queueVersion.incrementAndGet()
|
||||
cachedFlatIds = null
|
||||
}
|
||||
|
||||
private suspend fun writeUpNext(entries: List<QueueEntry>) {
|
||||
@@ -648,34 +840,40 @@ class QueueRepository(
|
||||
queueVersion.incrementAndGet()
|
||||
}
|
||||
|
||||
private suspend fun flatIds(snapshot: QueueSnapshot): List<String> {
|
||||
val cached = cachedFlatIds
|
||||
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached
|
||||
val ids = idsFor(snapshot.entries)
|
||||
cachedFlatIds = ids
|
||||
cachedFlatVersion = snapshot.version
|
||||
return ids
|
||||
private suspend fun flatIndexOf(
|
||||
entries: List<ResolvedQueueEntry>,
|
||||
songId: String,
|
||||
): Long? {
|
||||
var start = 0L
|
||||
for (resolved in entries) {
|
||||
val entry = resolved.entry
|
||||
val rank = resolverFor(entry.kind).rank(dao, entry, songId)
|
||||
if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) {
|
||||
return start + rank - entry.offset
|
||||
}
|
||||
val fallback = fallbackRank(entry, resolved.length, songId)
|
||||
if (fallback != null) return start + fallback - entry.offset
|
||||
start += resolved.length
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun idsFor(resolved: List<ResolvedQueueEntry>): List<String> {
|
||||
val ids = ArrayList<String>()
|
||||
for (entry in resolved) {
|
||||
val ref = entry.entry
|
||||
val entryIds =
|
||||
when (ref.kind) {
|
||||
QueueKind.Song -> listOf(ref.refId)
|
||||
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Genre -> dao.genreSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Downloaded -> dao.downloadedSongIds(ref.sourceId)
|
||||
QueueKind.Starred -> dao.starredSongIds(ref.sourceId)
|
||||
}
|
||||
val from = ref.offset.toInt().coerceIn(0, entryIds.size)
|
||||
val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size)
|
||||
ids.addAll(entryIds.subList(from, to))
|
||||
private suspend fun fallbackRank(
|
||||
entry: QueueEntry,
|
||||
length: Long,
|
||||
songId: String,
|
||||
): Long? {
|
||||
var offset = entry.offset
|
||||
val end = entry.offset + length
|
||||
while (offset < end) {
|
||||
val limit = minOf(QUEUE_CHUNK.toLong(), end - offset).toInt()
|
||||
val batch = rows(entry, offset, limit)
|
||||
val index = batch.indexOfFirst { it.song.id == songId }
|
||||
if (index >= 0) return offset + index
|
||||
if (batch.size < limit) return null
|
||||
offset += batch.size
|
||||
}
|
||||
return ids
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun contextPlayRange(
|
||||
@@ -686,21 +884,11 @@ class QueueRepository(
|
||||
if (first > last) return emptyList()
|
||||
val items = mutableListOf<QueueWindowItem>()
|
||||
if (snapshot.shuffled) {
|
||||
val sourceId =
|
||||
snapshot.entries
|
||||
.firstOrNull()
|
||||
?.entry
|
||||
?.sourceId ?: return emptyList()
|
||||
val ids = flatIds(snapshot)
|
||||
val requested = mutableListOf<Pair<Long, String>>()
|
||||
for (play in first..last) {
|
||||
val flat = snapshot.flatContext(play) ?: continue
|
||||
val id = ids.getOrNull(flat.toInt()) ?: continue
|
||||
requested += play to id
|
||||
val located = snapshot.locateContext(play) ?: continue
|
||||
val item = rows(located.first, located.first.offset + located.second, 1).firstOrNull() ?: continue
|
||||
items += QueueWindowItem(snapshot.combined(play), item, playlistPosition = item.playlistPosition)
|
||||
}
|
||||
if (requested.isEmpty()) return emptyList()
|
||||
val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id }
|
||||
requested.forEach { (play, id) -> byId[id]?.let { items += QueueWindowItem(snapshot.combined(play), it) } }
|
||||
return items
|
||||
}
|
||||
var start = 0L
|
||||
@@ -755,27 +943,20 @@ class QueueRepository(
|
||||
): List<SongItem> {
|
||||
if (entry.kind == QueueKind.Song) {
|
||||
offsetSeeks++
|
||||
return dao.song(entry.sourceId, entry.refId, offset, limit)
|
||||
return resolverFor(entry.kind).rows(dao, entry, offset, limit).also { resolvedRowCount += it.size }
|
||||
}
|
||||
val ref = QueueRef(entry.kind, entry.sourceId, entry.refId)
|
||||
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
|
||||
if (anchored != null) {
|
||||
keysetSeeks++
|
||||
if (anchored.isNotEmpty()) remember(ref, offset, anchored)
|
||||
resolvedRowCount += anchored.size
|
||||
return anchored
|
||||
}
|
||||
offsetSeeks++
|
||||
val fresh =
|
||||
when (entry.kind) {
|
||||
QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Artist -> dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Genre -> dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Downloaded -> dao.downloadedSongs(entry.sourceId, offset, limit)
|
||||
QueueKind.Starred -> dao.starredSongs(entry.sourceId, offset, limit)
|
||||
QueueKind.Song -> emptyList()
|
||||
}
|
||||
val fresh = resolverFor(entry.kind).rows(dao, entry, offset, limit)
|
||||
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
|
||||
resolvedRowCount += fresh.size
|
||||
return fresh
|
||||
}
|
||||
|
||||
@@ -808,7 +989,7 @@ class QueueRepository(
|
||||
limit: Int,
|
||||
cursor: KeyCursor,
|
||||
): List<SongItem>? {
|
||||
if (!cursor.key.seekable(entry.kind)) return null
|
||||
if (!resolverFor(entry.kind).supportsKeyset(cursor.key)) return null
|
||||
val from = cursor.ordinal
|
||||
// A backward seek from a far cursor is not cheaper than the offset query: the album
|
||||
// predicate is not sargable (it has to include NULL disc/track), so it scans the partition
|
||||
@@ -834,43 +1015,14 @@ class QueueRepository(
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem> =
|
||||
when (entry.kind) {
|
||||
QueueKind.Playlist -> dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
QueueKind.Album -> dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
QueueKind.Artist -> emptyList()
|
||||
QueueKind.Genre -> emptyList()
|
||||
QueueKind.Downloaded -> emptyList()
|
||||
QueueKind.Starred -> emptyList()
|
||||
QueueKind.Song -> emptyList()
|
||||
}
|
||||
): List<SongItem> = resolverFor(entry.kind).rowsFrom(dao, entry, key, skip, limit) ?: emptyList()
|
||||
|
||||
private suspend fun rowsBefore(
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem> =
|
||||
when (entry.kind) {
|
||||
QueueKind.Playlist -> dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
QueueKind.Album -> dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
QueueKind.Artist -> emptyList()
|
||||
QueueKind.Genre -> emptyList()
|
||||
QueueKind.Downloaded -> emptyList()
|
||||
QueueKind.Starred -> emptyList()
|
||||
QueueKind.Song -> emptyList()
|
||||
}
|
||||
|
||||
private fun OrderKey.seekable(kind: QueueKind): Boolean =
|
||||
when (kind) {
|
||||
QueueKind.Playlist -> position != null
|
||||
QueueKind.Album -> disc != null && track != null
|
||||
QueueKind.Artist -> false
|
||||
QueueKind.Genre -> false
|
||||
QueueKind.Downloaded -> false
|
||||
QueueKind.Starred -> false
|
||||
QueueKind.Song -> false
|
||||
}
|
||||
): List<SongItem> = resolverFor(entry.kind).rowsBefore(dao, entry, key, skip, limit) ?: emptyList()
|
||||
|
||||
private suspend fun lengthOf(
|
||||
entry: QueueEntry,
|
||||
@@ -895,40 +1047,23 @@ class QueueRepository(
|
||||
)
|
||||
|
||||
private suspend fun QueueEntry.resolvedLength(): Long {
|
||||
val total =
|
||||
when (kind) {
|
||||
QueueKind.Song -> {
|
||||
dao.songLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Playlist -> {
|
||||
dao.playlistLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Album -> {
|
||||
dao.albumLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Artist -> {
|
||||
dao.artistLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Genre -> {
|
||||
dao.genreLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Downloaded -> {
|
||||
dao.downloadedLength(sourceId)
|
||||
}
|
||||
|
||||
QueueKind.Starred -> {
|
||||
dao.starredLength(sourceId)
|
||||
}
|
||||
}
|
||||
val total = resolverFor(kind).length(dao, this)
|
||||
val available = (total - offset).coerceAtLeast(0)
|
||||
return count?.coerceAtMost(available) ?: available
|
||||
}
|
||||
|
||||
private fun movedEntry(
|
||||
source: QueueEntry,
|
||||
songId: String,
|
||||
offsetInEntry: Long,
|
||||
): QueueEntry =
|
||||
if (source.kind == QueueKind.Album) {
|
||||
val offset = source.offset + offsetInEntry
|
||||
source.range(offset, offset)
|
||||
} else {
|
||||
songEntry(source.sourceId, songId)
|
||||
}
|
||||
|
||||
private fun QueueEntry.range(
|
||||
start: Long,
|
||||
end: Long,
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.SubsonicConfig
|
||||
import com.subtracks.data.model.SubsonicSource
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
@@ -69,6 +70,9 @@ class SourceRepository(
|
||||
private val _offline = MutableStateFlow(false)
|
||||
val offline: StateFlow<Boolean> = _offline
|
||||
|
||||
private val _activeSource = MutableStateFlow<Long?>(null)
|
||||
val activeSource: StateFlow<Long?> = _activeSource
|
||||
|
||||
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
||||
|
||||
fun setOfflineMode(enabled: Boolean) {
|
||||
@@ -109,6 +113,7 @@ class SourceRepository(
|
||||
Log.i("net", "active source -> ${config?.name ?: "none"}")
|
||||
}
|
||||
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
||||
_activeSource.value = config?.id
|
||||
_quality.value = quality
|
||||
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
||||
scope.launch {
|
||||
@@ -143,6 +148,18 @@ class SourceRepository(
|
||||
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||
}
|
||||
|
||||
fun artistImage(
|
||||
artistId: String,
|
||||
url: String?,
|
||||
): CoverArtRef? {
|
||||
val current = active ?: return null
|
||||
if (url.isNullOrBlank()) return null
|
||||
val cacheKey = artistImageKey(current.id, artistId)
|
||||
artworkStore.uri(current.id, cacheKey)?.let { return CoverArtRef(url = it, cacheKey = cacheKey) }
|
||||
if (_offline.value) return null
|
||||
return CoverArtRef(url = url, cacheKey = cacheKey)
|
||||
}
|
||||
|
||||
fun networkCoverArt(
|
||||
sourceId: Long,
|
||||
coverArt: String,
|
||||
@@ -220,8 +237,8 @@ class SourceRepository(
|
||||
useTokenAuth: Boolean,
|
||||
) {
|
||||
val dao = db.sourcesDao()
|
||||
val source = dao.sourceOnce(id) ?: return
|
||||
dao.upsertSource(source.copy(name = name.trim(), address = normalizeAddress(address)))
|
||||
if (dao.sourceOnce(id) == null) return
|
||||
dao.updateSourceDetails(id, name.trim(), normalizeAddress(address))
|
||||
dao.upsertSubsonicSource(
|
||||
SubsonicSource(
|
||||
sourceId = id,
|
||||
@@ -261,6 +278,15 @@ class SourceRepository(
|
||||
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
|
||||
}
|
||||
|
||||
suspend fun artistBiography(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): String? {
|
||||
check(!_offline.value) { "Offline mode is on" }
|
||||
val config = db.sourcesDao().subsonicConfigOnce(sourceId) ?: error("No configured source")
|
||||
return config.toMusicSource(quality.value, prefs.syncConcurrency().first()).artistBiography(artistId)
|
||||
}
|
||||
|
||||
private fun SubsonicConfig.toClient(): SubsonicClient =
|
||||
client(
|
||||
address = address,
|
||||
|
||||
@@ -26,6 +26,8 @@ interface MusicSource {
|
||||
time: Long? = null,
|
||||
)
|
||||
|
||||
suspend fun artistBiography(artistId: String): String? = null
|
||||
|
||||
fun artists(): Flow<List<Artist>>
|
||||
|
||||
fun albums(): Flow<List<Album>>
|
||||
|
||||
@@ -8,6 +8,7 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
open class SubsonicException(
|
||||
val code: Int,
|
||||
@@ -55,11 +56,12 @@ class SubsonicClient(
|
||||
fun <T> stream(
|
||||
method: String,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
timeoutMs: Long? = null,
|
||||
body: (InputStream) -> T,
|
||||
): T {
|
||||
val usedTokenAuth = useTokenAuth
|
||||
return try {
|
||||
execute(method, params, body)
|
||||
execute(method, params, timeoutMs, body)
|
||||
} catch (failure: SubsonicException) {
|
||||
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) {
|
||||
Log.w("http", "$method failed: server code ${failure.code} \"${failure.message}\"")
|
||||
@@ -67,13 +69,14 @@ class SubsonicClient(
|
||||
}
|
||||
Log.w("http", "$method: token auth unsupported (server code ${failure.code}), retrying with plaintext")
|
||||
disableTokenAuth()
|
||||
execute(method, params, body)
|
||||
execute(method, params, timeoutMs, body)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> execute(
|
||||
method: String,
|
||||
params: Map<String, String>,
|
||||
timeoutMs: Long?,
|
||||
body: (InputStream) -> T,
|
||||
): T {
|
||||
val request =
|
||||
@@ -81,7 +84,9 @@ class SubsonicClient(
|
||||
.Builder()
|
||||
.url(uri(method, params))
|
||||
.build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
val call = http.newCall(request)
|
||||
timeoutMs?.let { call.timeout().timeout(it, TimeUnit.MILLISECONDS) }
|
||||
call.execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw SubsonicException(-1, "HTTP ${response.code}", UiMessage(R.string.error_http, listOf(response.code)))
|
||||
}
|
||||
|
||||
@@ -85,6 +85,13 @@ class SubsonicSource(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun artistBiography(artistId: String): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
client.stream("getArtistInfo2", mapOf("id" to artistId), timeoutMs = ARTIST_BIO_TIMEOUT_MS) { input ->
|
||||
SubsonicXml.artistBiography(input)
|
||||
}
|
||||
}
|
||||
|
||||
override fun artists(): Flow<List<Artist>> =
|
||||
entityBatches(
|
||||
method = "getArtists",
|
||||
@@ -103,7 +110,7 @@ class SubsonicSource(
|
||||
params = page("newest", offset),
|
||||
tag = "album",
|
||||
create = { SubsonicXml.albumDraft(id, it) },
|
||||
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
|
||||
onChild = SubsonicXml::albumChild,
|
||||
).collect { batch ->
|
||||
raw += batch.size
|
||||
val accepted = batch.filter { it.id.isNotEmpty() }
|
||||
@@ -323,7 +330,7 @@ class SubsonicSource(
|
||||
params: Map<String, String> = emptyMap(),
|
||||
tag: String,
|
||||
create: (Attributes) -> T,
|
||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
||||
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||
): Flow<List<T>> =
|
||||
channelFlow {
|
||||
val buffer = ArrayList<T>(PAGE_SIZE)
|
||||
@@ -365,6 +372,7 @@ class SubsonicSource(
|
||||
const val PAGE_SIZE = 500
|
||||
const val MAX_PAGES = 20_000
|
||||
const val THUMBNAIL_SIZE = 384
|
||||
const val ARTIST_BIO_TIMEOUT_MS = 15_000L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
@@ -31,6 +32,8 @@ object SubsonicXml {
|
||||
albumCount = attrs.longAttr("albumCount") ?: 0L,
|
||||
starred = attrs.dateAttr("starred"),
|
||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||
artistImageUrl = attrs.attr("artistImageUrl").ifEmpty { null },
|
||||
sortName = attrs.attr("sortName").takeIf { it.isNotBlank() } ?: attrs.attr("name"),
|
||||
)
|
||||
|
||||
internal fun albumDraft(
|
||||
@@ -43,6 +46,8 @@ object SubsonicXml {
|
||||
artistId = attrs.attr("artistId").ifEmpty { null },
|
||||
name = attrs.attr("name"),
|
||||
albumArtist = attrs.attr("artist").ifEmpty { null },
|
||||
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
|
||||
sortName = attrs.attr("sortName").ifEmpty { null },
|
||||
created = attrs.dateAttr("created") ?: 0L,
|
||||
coverArt = attrs.attr("coverArt").ifEmpty { null },
|
||||
genre = attrs.attr("genre").ifEmpty { null },
|
||||
@@ -68,6 +73,8 @@ object SubsonicXml {
|
||||
title = attrs.attr("title"),
|
||||
album = attrs.attr("album").ifEmpty { null },
|
||||
artist = attrs.attr("artist").ifEmpty { null },
|
||||
displayArtist = attrs.attr("displayArtist").ifEmpty { null },
|
||||
sortName = attrs.attr("sortName").ifEmpty { null },
|
||||
duration = attrs.longAttr("duration"),
|
||||
track = attrs.longAttr("track"),
|
||||
disc = attrs.longAttr("discNumber"),
|
||||
@@ -82,10 +89,29 @@ object SubsonicXml {
|
||||
entity: SongDraft,
|
||||
name: String,
|
||||
attrs: Attributes,
|
||||
level: Int,
|
||||
) {
|
||||
when (name) {
|
||||
"genres" -> entity.addGenre(attrs)
|
||||
"replayGain" -> entity.addReplayGain(attrs)
|
||||
"genres" -> if (level == 1) entity.addGenre(attrs)
|
||||
"replayGain" -> if (level == 1) entity.addReplayGain(attrs)
|
||||
"artists" -> if (level == 1) entity.addArtists(attrs)
|
||||
"albumArtists" -> entity.clearNestedArtists()
|
||||
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
||||
else -> if (level == 1) entity.clearNestedArtists()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun albumChild(
|
||||
entity: AlbumDraft,
|
||||
name: String,
|
||||
attrs: Attributes,
|
||||
level: Int,
|
||||
) {
|
||||
when (name) {
|
||||
"discTitles" -> if (level == 1) entity.addDiscTitle(attrs)
|
||||
"artists" -> if (level == 1) entity.addArtists(attrs)
|
||||
"artist" -> if (level == 2) entity.addNestedArtist(attrs)
|
||||
else -> if (level == 1) entity.clearArtists()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,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(
|
||||
@@ -126,7 +154,7 @@ object SubsonicXml {
|
||||
input: InputStream,
|
||||
entityTag: String,
|
||||
create: (Attributes) -> T,
|
||||
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
|
||||
onChild: (T, String, Attributes, Int) -> Unit = { _, _, _, _ -> },
|
||||
onEnd: (T) -> Unit,
|
||||
) {
|
||||
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
|
||||
@@ -136,10 +164,16 @@ object SubsonicXml {
|
||||
input: InputStream,
|
||||
entityTag: String,
|
||||
): Boolean {
|
||||
val handler = EntityHandler(entityTag, { Unit }, { _, _, _ -> }, { }, stopAfterFirst = true)
|
||||
val handler = EntityHandler(entityTag, { Unit }, { _, _, _, _ -> }, { }, stopAfterFirst = true)
|
||||
parse(input, handler)
|
||||
return handler.found
|
||||
}
|
||||
|
||||
internal fun artistBiography(input: InputStream): String? {
|
||||
val handler = ArtistBiographyHandler()
|
||||
parse(input, handler)
|
||||
return handler.biography
|
||||
}
|
||||
}
|
||||
|
||||
internal class AlbumDraft(
|
||||
@@ -148,6 +182,8 @@ internal class AlbumDraft(
|
||||
var artistId: String? = null,
|
||||
var name: String = "",
|
||||
var albumArtist: String? = null,
|
||||
var displayArtist: String? = null,
|
||||
var sortName: String? = null,
|
||||
var created: Long = 0L,
|
||||
var coverArt: String? = null,
|
||||
var genre: String? = null,
|
||||
@@ -156,19 +192,41 @@ internal class AlbumDraft(
|
||||
var songCount: Long = 0L,
|
||||
val discTitles: MutableMap<Long, String> = mutableMapOf(),
|
||||
) {
|
||||
private val artists = mutableListOf<ArtistRef>()
|
||||
private var artistContainer = false
|
||||
|
||||
fun addDiscTitle(attrs: Attributes) {
|
||||
val disc = attrs.longAttr("disc") ?: return
|
||||
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
|
||||
discTitles[disc] = title
|
||||
}
|
||||
|
||||
fun toAlbum(): Album =
|
||||
Album(
|
||||
fun addArtists(attrs: Attributes) {
|
||||
val ref = attrs.artistRef()
|
||||
if (ref == null) {
|
||||
artistContainer = true
|
||||
} else {
|
||||
artists += ref
|
||||
artistContainer = false
|
||||
}
|
||||
}
|
||||
|
||||
fun addNestedArtist(attrs: Attributes) {
|
||||
if (artistContainer) attrs.artistRef()?.let { artists += it }
|
||||
}
|
||||
|
||||
fun clearArtists() {
|
||||
artistContainer = false
|
||||
}
|
||||
|
||||
fun toAlbum(): Album {
|
||||
val refs = artistFallback(artists, artistId, albumArtist)
|
||||
return Album(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
artistId = artistId,
|
||||
name = name,
|
||||
albumArtist = albumArtist,
|
||||
albumArtist = displayName(albumArtist, displayArtist, refs),
|
||||
created = created,
|
||||
coverArt = coverArt,
|
||||
genre = genre,
|
||||
@@ -176,7 +234,10 @@ internal class AlbumDraft(
|
||||
starred = starred,
|
||||
songCount = songCount,
|
||||
discTitles = discTitles.toMap(),
|
||||
artists = refs,
|
||||
sortName = sortName?.takeIf { it.isNotBlank() } ?: name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal class SongDraft(
|
||||
@@ -187,6 +248,8 @@ internal class SongDraft(
|
||||
private val title: String = "",
|
||||
private val album: String? = null,
|
||||
private val artist: String? = null,
|
||||
private val displayArtist: String? = null,
|
||||
private val sortName: String? = null,
|
||||
private val duration: Long? = null,
|
||||
private val track: Long? = null,
|
||||
private val disc: Long? = null,
|
||||
@@ -201,6 +264,9 @@ internal class SongDraft(
|
||||
private var albumPeak: Float? = null,
|
||||
private val genres: MutableList<String> = mutableListOf(),
|
||||
) {
|
||||
private val artists = mutableListOf<ArtistRef>()
|
||||
private var nestedArtists: MutableList<ArtistRef>? = null
|
||||
|
||||
fun addGenre(attrs: Attributes) {
|
||||
attrs
|
||||
.attr("name")
|
||||
@@ -216,15 +282,34 @@ internal class SongDraft(
|
||||
attrs.floatAttr("albumPeak")?.let { albumPeak = it }
|
||||
}
|
||||
|
||||
fun toSong(): Song =
|
||||
Song(
|
||||
fun addArtists(attrs: Attributes) {
|
||||
val ref = attrs.artistRef()
|
||||
if (ref == null) {
|
||||
nestedArtists = artists
|
||||
} else {
|
||||
artists += ref
|
||||
nestedArtists = null
|
||||
}
|
||||
}
|
||||
|
||||
fun clearNestedArtists() {
|
||||
nestedArtists = null
|
||||
}
|
||||
|
||||
fun addNestedArtist(attrs: Attributes) {
|
||||
nestedArtists?.let { list -> attrs.artistRef()?.let { list += it } }
|
||||
}
|
||||
|
||||
fun toSong(): Song {
|
||||
val refs = artistFallback(artists, artistId, artist)
|
||||
return Song(
|
||||
sourceId = sourceId,
|
||||
id = id,
|
||||
albumId = albumId,
|
||||
artistId = artistId,
|
||||
title = title,
|
||||
album = album,
|
||||
artist = artist,
|
||||
artist = displayName(artist, displayArtist, refs),
|
||||
duration = duration,
|
||||
track = track,
|
||||
disc = disc,
|
||||
@@ -238,13 +323,52 @@ internal class SongDraft(
|
||||
trackPeak = trackPeak,
|
||||
albumPeak = albumPeak,
|
||||
genres = genres.toList(),
|
||||
artists = refs,
|
||||
sortName = sortName?.takeIf { it.isNotBlank() } ?: title,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Attributes.artistRef(): ArtistRef? {
|
||||
val id = attr("id")
|
||||
val name = attr("name")
|
||||
if (id.isEmpty() && name.isEmpty()) return null
|
||||
return ArtistRef(id, name)
|
||||
}
|
||||
|
||||
private fun artistFallback(
|
||||
parsed: List<ArtistRef>,
|
||||
artistId: String?,
|
||||
name: String?,
|
||||
): List<ArtistRef> {
|
||||
val refs = parsed.filter { it.id.isNotBlank() }.distinctBy { it.id }
|
||||
if (refs.isNotEmpty()) return refs
|
||||
if (artistId.isNullOrBlank()) return emptyList()
|
||||
return listOf(ArtistRef(artistId, name.orEmpty()))
|
||||
}
|
||||
|
||||
private const val ARTIST_JOINER = " • "
|
||||
|
||||
private fun displayName(
|
||||
legacy: String?,
|
||||
openSubsonic: String?,
|
||||
refs: List<ArtistRef>,
|
||||
): String? =
|
||||
legacy?.takeIf { it.isNotBlank() }
|
||||
?: openSubsonic?.takeIf { it.isNotBlank() }
|
||||
?: refs
|
||||
.map { it.name }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.joinToString(ARTIST_JOINER)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
|
||||
|
||||
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
||||
|
||||
internal fun Attributes.boolAttr(name: String): Boolean = attr(name).let { it == "1" || it.equals("true", ignoreCase = true) }
|
||||
|
||||
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() }
|
||||
|
||||
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
||||
@@ -308,10 +432,44 @@ private open class SubsonicResponseHandler : DefaultHandler() {
|
||||
) = Unit
|
||||
}
|
||||
|
||||
private class ArtistBiographyHandler : SubsonicResponseHandler() {
|
||||
private var biographyDepth = -1
|
||||
private val text = StringBuilder()
|
||||
|
||||
var biography: String? = null
|
||||
private set
|
||||
|
||||
override fun characters(
|
||||
ch: CharArray,
|
||||
start: Int,
|
||||
length: Int,
|
||||
) {
|
||||
if (biographyDepth >= 0) text.append(ch, start, length)
|
||||
}
|
||||
|
||||
override fun onElementStart(
|
||||
name: String,
|
||||
attributes: Attributes,
|
||||
depth: Int,
|
||||
) {
|
||||
if (name == "biography" && biographyDepth < 0) biographyDepth = depth
|
||||
}
|
||||
|
||||
override fun onElementEnd(
|
||||
name: String,
|
||||
depth: Int,
|
||||
) {
|
||||
if (name == "biography" && depth == biographyDepth) {
|
||||
biography = text.toString().trim().takeIf { it.isNotEmpty() }
|
||||
biographyDepth = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class EntityHandler<T>(
|
||||
private val entityTag: String,
|
||||
private val create: (Attributes) -> T,
|
||||
private val onChild: (T, String, Attributes) -> Unit,
|
||||
private val onChild: (T, String, Attributes, Int) -> Unit,
|
||||
private val onEnd: (T) -> Unit,
|
||||
private val stopAfterFirst: Boolean,
|
||||
) : SubsonicResponseHandler() {
|
||||
@@ -332,8 +490,8 @@ private class EntityHandler<T>(
|
||||
current = create(attributes)
|
||||
entityDepth = depth
|
||||
}
|
||||
} else if (depth == entityDepth + 1) {
|
||||
onChild(active, name, attributes)
|
||||
} else if (depth > entityDepth) {
|
||||
onChild(active, name, attributes, depth - entityDepth)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,16 @@ import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.SQLiteStatement
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumArtistRef
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.ArtistRefsCodec
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.DiscKey
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongArtistRef
|
||||
import com.subtracks.data.model.SongGenre
|
||||
import com.subtracks.data.source.MusicSource
|
||||
import java.util.Arrays
|
||||
@@ -28,10 +32,16 @@ class SyncService(
|
||||
private var pruned = 0
|
||||
|
||||
suspend fun sync(): SyncSummary {
|
||||
syncArtists()
|
||||
syncAlbums()
|
||||
syncSongs()
|
||||
aggregatePlayData()
|
||||
val sources = db.sourcesDao()
|
||||
val wasStale = sources.creditsStale(source.id) ?: true
|
||||
val artistsChanged = syncArtists()
|
||||
val albumsChanged = syncAlbums()
|
||||
val songsChanged = syncSongs()
|
||||
optimize()
|
||||
if (artistsChanged || albumsChanged || songsChanged || wasStale) {
|
||||
recomputeAggregates()
|
||||
sources.setCreditsStale(source.id, false)
|
||||
}
|
||||
syncPlaylists()
|
||||
syncPlaylistSongs()
|
||||
return SyncSummary(
|
||||
@@ -44,61 +54,90 @@ class SyncService(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun syncArtists() {
|
||||
private suspend fun syncArtists(): Boolean {
|
||||
val library = db.libraryDao()
|
||||
val seen = HashedIds()
|
||||
var changed = false
|
||||
source.artists().collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("artists", artistColumns, primaryKey, batch) { it.values() }
|
||||
changed =
|
||||
write("artists", primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.columns() } ||
|
||||
changed
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
artistsSynced += batch.size
|
||||
}
|
||||
val prunedBefore = pruned
|
||||
pruned += pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
||||
return changed || pruned > prunedBefore
|
||||
}
|
||||
|
||||
private suspend fun syncAlbums() {
|
||||
private suspend fun syncAlbums(): Boolean {
|
||||
val library = db.libraryDao()
|
||||
val seenAlbums = HashedIds()
|
||||
val seenDiscs = HashedIds()
|
||||
var changed = false
|
||||
source.albums().collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
val discs =
|
||||
batch.flatMap { album ->
|
||||
album.discTitles.map { (disc, title) -> Disc(album.sourceId, album.id, disc, title) }
|
||||
}
|
||||
writeAlbumsAndDiscs(batch, discs)
|
||||
changed = writeAlbumsAndDiscs(batch, discs) || changed
|
||||
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
||||
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
||||
albumsSynced += batch.size
|
||||
}
|
||||
pruned += pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
|
||||
val prunedBefore = pruned
|
||||
pruned +=
|
||||
pruneStaleIds(
|
||||
seenAlbums,
|
||||
{ library.albumIdsAfter(source.id, it, PRUNE_PAGE) },
|
||||
{ library.deleteAlbums(source.id, it) },
|
||||
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||
)
|
||||
pruned +=
|
||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||
deleteStaleDiscs(it)
|
||||
}
|
||||
return changed || pruned > prunedBefore
|
||||
}
|
||||
|
||||
private suspend fun syncSongs() {
|
||||
private suspend fun syncSongs(): Boolean {
|
||||
val library = db.libraryDao()
|
||||
val seen = HashedIds()
|
||||
var changed = false
|
||||
source.songs().collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
writeSongs(batch)
|
||||
changed = writeSongs(batch) || changed
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
songsSynced += batch.size
|
||||
}
|
||||
pruned += pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
|
||||
val prunedBefore = pruned
|
||||
pruned +=
|
||||
pruneStaleIds(
|
||||
seen,
|
||||
{ library.songIdsAfter(source.id, it, PRUNE_PAGE) },
|
||||
{ library.deleteSongs(source.id, it) },
|
||||
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||
)
|
||||
return changed || pruned > prunedBefore
|
||||
}
|
||||
|
||||
private suspend fun writeSongs(batch: List<Song>) {
|
||||
private suspend fun writeSongs(batch: List<Song>): Boolean {
|
||||
val names = batch.associate { it.id to it.genreNames() }
|
||||
val genres =
|
||||
batch.flatMap { song ->
|
||||
names.getValue(song.id).mapIndexed { index, genre -> SongGenre(song.sourceId, song.id, index.toLong(), genre) }
|
||||
}
|
||||
db.useWriterConnection { connection ->
|
||||
val artists =
|
||||
batch.flatMap { song ->
|
||||
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
|
||||
}
|
||||
return db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
connection.upsertChanged("songs", songColumns, primaryKey, batch) { it.values() }
|
||||
val before = connection.totalChanges()
|
||||
val resolved = connection.resolveSongSortNames(batch)
|
||||
connection.upsertChanged("songs", 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)
|
||||
@@ -109,13 +148,107 @@ 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)
|
||||
statement.bind(2, song.id)
|
||||
statement.bind(3, song.artistPositions().size.toLong())
|
||||
statement.step()
|
||||
statement.reset()
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("song_artists", songArtistKey, artists) { it.columns() }
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed) connection.markCreditsStale(batch.first().sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun aggregatePlayData() {
|
||||
db.libraryDao().recomputePlayData(source.id)
|
||||
private suspend fun recomputeAggregates() {
|
||||
db.libraryDao().recomputeAggregates(source.id)
|
||||
}
|
||||
|
||||
private suspend fun PooledConnection.totalChanges(): Long {
|
||||
var value = 0L
|
||||
usePrepared("SELECT total_changes()") { statement ->
|
||||
statement.step()
|
||||
value = statement.getLong(0)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private suspend fun PooledConnection.markCreditsStale(sourceId: Long) {
|
||||
usePrepared("UPDATE sources SET creditsStale = 1 WHERE id = ? AND creditsStale = 0") { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun PooledConnection.resolveSongSortNames(batch: List<Song>): List<Song> {
|
||||
if (batch.isEmpty()) return batch
|
||||
val sortNames =
|
||||
artistSortNames(
|
||||
batch.first().sourceId,
|
||||
batch
|
||||
.flatMap { listOfNotNull(it.artistId) + it.artistRefs.map { ref -> ref.id } }
|
||||
.filter { it.isNotBlank() }
|
||||
.toSet(),
|
||||
)
|
||||
return batch.map { song ->
|
||||
val primary = song.artistId ?: song.artists.firstOrNull()?.id
|
||||
val sortName =
|
||||
primary?.let(sortNames::get)?.takeIf { it.isNotBlank() }
|
||||
?: song.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } }
|
||||
?: song.artistSort
|
||||
if (sortName == song.artistSort) song else song.copy(artistSort = sortName)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun PooledConnection.resolveAlbumSortNames(batch: List<Album>): List<Album> {
|
||||
if (batch.isEmpty()) return batch
|
||||
val sortNames =
|
||||
artistSortNames(
|
||||
batch.first().sourceId,
|
||||
batch
|
||||
.flatMap { listOfNotNull(it.artistId) + it.artistRefs.map { ref -> ref.id } }
|
||||
.filter { it.isNotBlank() }
|
||||
.toSet(),
|
||||
)
|
||||
return batch.map { album ->
|
||||
val primary = album.artistId ?: album.artists.firstOrNull()?.id
|
||||
val sortName =
|
||||
primary?.let(sortNames::get)?.takeIf { it.isNotBlank() }
|
||||
?: album.artists.firstNotNullOfOrNull { ref -> sortNames[ref.id]?.takeIf { it.isNotBlank() } }
|
||||
?: album.albumArtistSort
|
||||
if (sortName == album.albumArtistSort) album else album.copy(albumArtistSort = sortName)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun PooledConnection.artistSortNames(
|
||||
sourceId: Long,
|
||||
ids: Collection<String>,
|
||||
): Map<String, String> {
|
||||
if (ids.isEmpty()) return emptyMap()
|
||||
val sortNames = HashMap<String, String>(ids.size)
|
||||
ids.toList().chunked(SQL_VARIABLES).forEach { chunk ->
|
||||
val placeholders = chunk.joinToString(",") { "?" }
|
||||
usePrepared("SELECT id, sortName FROM artists WHERE sourceId = ? AND id IN ($placeholders)") { statement ->
|
||||
statement.bindLong(1, sourceId)
|
||||
chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) }
|
||||
while (statement.step()) sortNames[statement.getText(0)] = statement.getText(1)
|
||||
}
|
||||
}
|
||||
return sortNames
|
||||
}
|
||||
|
||||
// Without sqlite_stat1 the artist and relation queries can fall back to whole-table scans.
|
||||
private suspend fun optimize() {
|
||||
db.useWriterConnection { connection ->
|
||||
connection.usePrepared("PRAGMA optimize") { it.step() }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncPlaylists() {
|
||||
@@ -123,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
|
||||
}
|
||||
@@ -136,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
|
||||
}
|
||||
@@ -147,26 +280,50 @@ class SyncService(
|
||||
// One commit per batch, so Room notifies paged observers once per batch rather than per row.
|
||||
private suspend fun <T> write(
|
||||
table: String,
|
||||
columns: List<String>,
|
||||
key: List<String>,
|
||||
rows: List<T>,
|
||||
values: (T) -> List<Any?>,
|
||||
) {
|
||||
preserved: Set<String> = emptySet(),
|
||||
markStale: Boolean = false,
|
||||
columns: (T) -> List<Pair<String, Any?>>,
|
||||
): Boolean =
|
||||
db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
connection.upsertChanged(table, columns, key, rows, values)
|
||||
val before = connection.totalChanges()
|
||||
connection.upsertChanged(table, key, rows, preserved, columns)
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed && markStale) connection.markCreditsStale(source.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun writeAlbumsAndDiscs(
|
||||
albums: List<Album>,
|
||||
discs: List<Disc>,
|
||||
) {
|
||||
db.useWriterConnection { connection ->
|
||||
): Boolean {
|
||||
val artists =
|
||||
albums.flatMap { album ->
|
||||
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
|
||||
}
|
||||
return db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
connection.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
|
||||
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
||||
val before = connection.totalChanges()
|
||||
val resolved = connection.resolveAlbumSortNames(albums)
|
||||
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)
|
||||
statement.bind(2, album.id)
|
||||
statement.bind(3, album.artistPositions().size.toLong())
|
||||
statement.step()
|
||||
statement.reset()
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("album_artists", albumArtistKey, artists) { it.columns() }
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed) connection.markCreditsStale(albums.first().sourceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,6 +351,7 @@ class SyncService(
|
||||
seen: HashedIds,
|
||||
pageOf: suspend (afterId: String) -> List<String>,
|
||||
delete: suspend (Collection<String>) -> Unit,
|
||||
markStale: (suspend () -> Unit)? = null,
|
||||
): Int {
|
||||
seen.freeze()
|
||||
var afterId = ""
|
||||
@@ -202,6 +360,7 @@ class SyncService(
|
||||
val ids = pageOf(afterId)
|
||||
if (ids.isEmpty()) return pruned
|
||||
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
||||
if (stale.isNotEmpty()) markStale?.invoke()
|
||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||
pruned += stale.size
|
||||
if (ids.size < PRUNE_PAGE) return pruned
|
||||
@@ -241,6 +400,7 @@ data class SyncSummary(
|
||||
)
|
||||
|
||||
private const val DELETE_CHUNK = 500
|
||||
private const val SQL_VARIABLES = 500
|
||||
private const val PRUNE_PAGE = 500
|
||||
private const val INITIAL_HASH_CAPACITY = 256
|
||||
private const val FNV_OFFSET = -3750763034362895579L
|
||||
@@ -277,51 +437,8 @@ private fun discHash(
|
||||
|
||||
private val primaryKey = listOf("sourceId", "id")
|
||||
|
||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt")
|
||||
|
||||
private val albumColumns =
|
||||
listOf(
|
||||
"sourceId",
|
||||
"id",
|
||||
"artistId",
|
||||
"name",
|
||||
"albumArtist",
|
||||
"created",
|
||||
"coverArt",
|
||||
"genre",
|
||||
"year",
|
||||
"starred",
|
||||
"songCount",
|
||||
)
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
||||
|
||||
private fun Song.genreNames(): List<String> =
|
||||
genres
|
||||
.flatMap { it.genreParts() }
|
||||
@@ -332,67 +449,154 @@ 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 playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
||||
private val albumArtistKey = listOf("sourceId", "albumId", "position")
|
||||
|
||||
private val songArtistKey = listOf("sourceId", "songId", "position")
|
||||
|
||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||
|
||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt)
|
||||
|
||||
private fun Album.values() = listOf<Any?>(sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount)
|
||||
|
||||
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
||||
|
||||
private fun Song.values() =
|
||||
listOf<Any?>(
|
||||
sourceId,
|
||||
id,
|
||||
albumId,
|
||||
artistId,
|
||||
title,
|
||||
album,
|
||||
artist,
|
||||
duration,
|
||||
track,
|
||||
disc,
|
||||
starred,
|
||||
created,
|
||||
playCount,
|
||||
played,
|
||||
trackGain,
|
||||
albumGain,
|
||||
trackPeak,
|
||||
albumPeak,
|
||||
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 SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
||||
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 Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
||||
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 PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
|
||||
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 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>> =
|
||||
filter { it.id.isNotBlank() }
|
||||
.distinctBy { it.id }
|
||||
.mapIndexed { index, ref -> index.toLong() to ref }
|
||||
|
||||
private fun Album.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
||||
|
||||
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
||||
|
||||
private fun SongGenre.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"songId" to songId,
|
||||
"position" to position,
|
||||
"genre" to genre,
|
||||
)
|
||||
|
||||
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.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"playlistId" to playlistId,
|
||||
"songId" to songId,
|
||||
"position" to position,
|
||||
)
|
||||
|
||||
private suspend fun <T> PooledConnection.upsertChanged(
|
||||
table: String,
|
||||
columns: List<String>,
|
||||
key: List<String>,
|
||||
rows: List<T>,
|
||||
values: (T) -> List<Any?>,
|
||||
preserved: Set<String> = emptySet(),
|
||||
columns: (T) -> List<Pair<String, Any?>>,
|
||||
) {
|
||||
if (rows.isEmpty()) return
|
||||
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
|
||||
val updates = columns.filterNot { it in key }
|
||||
val columnNames = columns(rows.first()).map { it.first }
|
||||
val updates = columnNames.filterNot { it in key || it in preserved }
|
||||
val sql =
|
||||
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
|
||||
"INSERT INTO $table (${columnNames.joinToString()}) VALUES (${columnNames.joinToString { "?" }}) " +
|
||||
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
||||
updates.joinToString { "$it = excluded.$it" } +
|
||||
" WHERE " +
|
||||
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" }
|
||||
usePrepared(sql) { statement ->
|
||||
rows.forEach { row ->
|
||||
values(row).forEachIndexed { index, value -> statement.bind(index + 1, value) }
|
||||
columns(row).forEachIndexed { index, (_, value) -> statement.bind(index + 1, value) }
|
||||
statement.step()
|
||||
statement.reset()
|
||||
statement.clearBindings()
|
||||
|
||||
@@ -96,7 +96,7 @@ fun appModule(
|
||||
scope = get(),
|
||||
)
|
||||
}
|
||||
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
|
||||
single { LibraryRepository(get(), get(), get(), get(), toast, scope = get(named("io"))) }
|
||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
||||
single {
|
||||
|
||||
@@ -12,6 +12,8 @@ import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.ArtistRefsCodec
|
||||
import com.subtracks.data.model.AudioEncoding
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
@@ -229,6 +231,7 @@ class Media3PlayerHandle(
|
||||
.orEmpty(),
|
||||
artist = mediaItem.mediaMetadata.artist?.toString(),
|
||||
album = mediaItem.mediaMetadata.albumTitle?.toString(),
|
||||
artists = mediaItem.artistRefs(),
|
||||
coverArtId = mediaItem.mediaMetadata.extras?.getString(EXTRA_COVER_ART_ID),
|
||||
durationMs =
|
||||
mediaItem.mediaMetadata.extras
|
||||
@@ -238,12 +241,20 @@ class Media3PlayerHandle(
|
||||
)
|
||||
}
|
||||
|
||||
internal fun MediaItem.artistRefs(): List<ArtistRef> =
|
||||
mediaMetadata.extras
|
||||
?.getString(EXTRA_ARTISTS)
|
||||
?.let(ArtistRefsCodec::decode)
|
||||
.orEmpty()
|
||||
|
||||
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
||||
private const val EXTRA_ARTISTS = "artists"
|
||||
private const val EXTRA_DURATION_MS = "durationMs"
|
||||
private const val EXTRA_REPLAY_GAIN_TRACK_GAIN = "replayGainTrackGain"
|
||||
private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
||||
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
|
||||
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
|
||||
private const val EXTRA_ALBUM_CONTEXT = "albumContext"
|
||||
|
||||
internal fun MediaItem?.replayGain(): ReplayGain {
|
||||
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
|
||||
@@ -255,6 +266,8 @@ internal fun MediaItem?.replayGain(): ReplayGain {
|
||||
)
|
||||
}
|
||||
|
||||
internal fun MediaItem?.albumContext(): Boolean = this?.mediaMetadata?.extras?.getBoolean(EXTRA_ALBUM_CONTEXT) ?: false
|
||||
|
||||
private fun Bundle.putGain(
|
||||
key: String,
|
||||
value: Float?,
|
||||
@@ -283,11 +296,13 @@ internal fun queueMediaItem(
|
||||
.setExtras(
|
||||
Bundle().apply {
|
||||
putString(EXTRA_COVER_ART_ID, item.coverArtId)
|
||||
putString(EXTRA_ARTISTS, ArtistRefsCodec.encode(item.artists))
|
||||
item.durationMs?.let { putLong(EXTRA_DURATION_MS, it) }
|
||||
putGain(EXTRA_REPLAY_GAIN_TRACK_GAIN, item.replayGain.trackGain)
|
||||
putGain(EXTRA_REPLAY_GAIN_ALBUM_GAIN, item.replayGain.albumGain)
|
||||
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
|
||||
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
|
||||
putBoolean(EXTRA_ALBUM_CONTEXT, item.albumContext)
|
||||
},
|
||||
).build()
|
||||
val builder =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.AudioEncoding
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.QueueEntry
|
||||
@@ -35,6 +36,8 @@ data class QueueItem(
|
||||
val coverArtId: String?,
|
||||
val durationMs: Long? = null,
|
||||
val replayGain: ReplayGain = ReplayGain(),
|
||||
val albumContext: Boolean = false,
|
||||
val artists: List<ArtistRef> = emptyList(),
|
||||
)
|
||||
|
||||
data class QueueContext(
|
||||
@@ -393,7 +396,7 @@ class PlaybackController(
|
||||
val current = currentPosition() ?: return@withLock
|
||||
val playing = player.playWhenReady
|
||||
val wasUpNext = snapshot.isUpNext(current)
|
||||
val removed = if (position == current) null else queueRepository.itemAt(snapshot, position)?.toQueueItem()
|
||||
val removed = if (position == current) null else queueItemAt(snapshot, position)
|
||||
queueRepository.removeAt(snapshot, position)
|
||||
var updated = readSnapshot()
|
||||
this.snapshot = updated
|
||||
@@ -545,13 +548,13 @@ class PlaybackController(
|
||||
return@withLock
|
||||
}
|
||||
when {
|
||||
fromInWindow && toInWindow -> {
|
||||
fromInWindow && toInWindow && (snapshot.upNextSize == 0L || snapshot.isUpNext(from)) -> {
|
||||
updating = true
|
||||
player.move((from - windowStart).toInt(), (to - windowStart).toInt())
|
||||
updating = false
|
||||
}
|
||||
|
||||
!fromInWindow && !toInWindow -> {
|
||||
!fromInWindow && !toInWindow && (snapshot.upNextSize == 0L || snapshot.isUpNext(from)) -> {
|
||||
// The moved track never enters the window, so the playlist is unchanged; only
|
||||
// the window's positions shift, and a move that spans it shifts them by one.
|
||||
when {
|
||||
@@ -693,12 +696,29 @@ class PlaybackController(
|
||||
}
|
||||
return
|
||||
}
|
||||
when {
|
||||
current < snapshot.size - 1 -> jumpTo(current + 1)
|
||||
repeatMode != RepeatMode.Off -> jumpTo(0)
|
||||
}
|
||||
nextPosition(snapshot, current)?.let { jumpTo(it) }
|
||||
}
|
||||
|
||||
private fun nextPosition(
|
||||
snapshot: QueueSnapshot,
|
||||
current: Long,
|
||||
): Long? =
|
||||
when {
|
||||
current < snapshot.size - 1 -> current + 1
|
||||
repeatMode != RepeatMode.Off -> 0L
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun previousPosition(
|
||||
snapshot: QueueSnapshot,
|
||||
current: Long,
|
||||
): Long? =
|
||||
when {
|
||||
current > 0 -> current - 1
|
||||
repeatMode == RepeatMode.All -> snapshot.size - 1
|
||||
else -> null
|
||||
}
|
||||
|
||||
private suspend fun consumeUpNext(position: Long) =
|
||||
startLock.withLock {
|
||||
val snapshot = snapshot ?: return@withLock
|
||||
@@ -784,19 +804,12 @@ class PlaybackController(
|
||||
}
|
||||
return
|
||||
}
|
||||
when {
|
||||
current > 0 -> {
|
||||
jumpTo(current - 1)
|
||||
}
|
||||
|
||||
repeatMode == RepeatMode.All -> {
|
||||
jumpTo(snapshot.size - 1)
|
||||
}
|
||||
|
||||
else -> {
|
||||
player.seekTo(0)
|
||||
refresh()
|
||||
}
|
||||
val target = previousPosition(snapshot, current)
|
||||
if (target != null) {
|
||||
jumpTo(target)
|
||||
} else {
|
||||
player.seekTo(0)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,10 +827,7 @@ class PlaybackController(
|
||||
}
|
||||
return
|
||||
}
|
||||
when {
|
||||
current > 0 -> jumpTo(current - 1)
|
||||
repeatMode == RepeatMode.All -> jumpTo(snapshot.size - 1)
|
||||
}
|
||||
previousPosition(snapshot, current)?.let { jumpTo(it) }
|
||||
}
|
||||
|
||||
fun addToQueue(
|
||||
@@ -934,12 +944,16 @@ class PlaybackController(
|
||||
}
|
||||
val snapshot = current ?: return null
|
||||
val at = position ?: return null
|
||||
if (!isOffline) return queueRepository.itemAt(snapshot, at + 1)?.toQueueItem()
|
||||
if (!isOffline) {
|
||||
return nextPosition(snapshot, at)
|
||||
?.takeIf { it != at }
|
||||
?.let { queueItemAt(snapshot, it) }
|
||||
}
|
||||
downloads.awaitLoaded()
|
||||
val target =
|
||||
downloadedAtOrAfter(snapshot, at + 1)
|
||||
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
|
||||
return target?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
||||
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
|
||||
}
|
||||
|
||||
suspend fun previousItem(): QueueItem? {
|
||||
@@ -953,12 +967,16 @@ class PlaybackController(
|
||||
}
|
||||
val snapshot = current ?: return null
|
||||
val at = position ?: return null
|
||||
if (!isOffline) return if (at <= 0) null else queueRepository.itemAt(snapshot, at - 1)?.toQueueItem()
|
||||
if (!isOffline) {
|
||||
return previousPosition(snapshot, at)
|
||||
?.takeIf { it != at }
|
||||
?.let { queueItemAt(snapshot, it) }
|
||||
}
|
||||
downloads.awaitLoaded()
|
||||
val target =
|
||||
downloadedAtOrBefore(snapshot, at - 1)
|
||||
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
|
||||
return target?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
|
||||
return target?.takeIf { it != at }?.let { queueItemAt(snapshot, it) }
|
||||
}
|
||||
|
||||
private fun play(
|
||||
@@ -1117,7 +1135,7 @@ class PlaybackController(
|
||||
lastPosition = actual
|
||||
if (actual != requested) queueRepository.setCursor(actual)
|
||||
updating = true
|
||||
player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs)
|
||||
player.setWindow(window.map { it.playerItem(snapshot) }, startIndex, startPositionMs)
|
||||
updating = false
|
||||
if (autoplay) {
|
||||
player.prepare()
|
||||
@@ -1157,7 +1175,7 @@ class PlaybackController(
|
||||
window.forEachIndexed { index, entry ->
|
||||
val local = downloads.localUri(entry.item.song.id) != null
|
||||
if (local != (uris[index]?.startsWith(FILE_SCHEME) == true)) {
|
||||
player.replaceItem(index, entry.item.toQueueItem())
|
||||
player.replaceItem(index, entry.playerItem(snapshot))
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
@@ -1272,8 +1290,8 @@ class PlaybackController(
|
||||
updating = true
|
||||
try {
|
||||
while (windowStart > desiredStart) {
|
||||
val item = queueRepository.itemAt(snapshot, windowStart - 1) ?: break
|
||||
player.addFirst(item.toQueueItem())
|
||||
val item = queueItemAt(snapshot, windowStart - 1) ?: break
|
||||
player.addFirst(item)
|
||||
windowStart--
|
||||
}
|
||||
while (windowStart < desiredStart) {
|
||||
@@ -1281,8 +1299,8 @@ class PlaybackController(
|
||||
windowStart++
|
||||
}
|
||||
while (windowEnd < desiredEnd) {
|
||||
val item = queueRepository.itemAt(snapshot, windowEnd + 1) ?: break
|
||||
player.addLast(item.toQueueItem())
|
||||
val item = queueItemAt(snapshot, windowEnd + 1) ?: break
|
||||
player.addLast(item)
|
||||
windowEnd++
|
||||
}
|
||||
while (windowEnd > desiredEnd) {
|
||||
@@ -1300,6 +1318,7 @@ class PlaybackController(
|
||||
position: Long,
|
||||
) {
|
||||
val player = player ?: return
|
||||
val snapshot = this.snapshot ?: return
|
||||
downloads.awaitLoaded()
|
||||
val ordered =
|
||||
if (offline) window.filter { downloads.localUri(it.item.song.id) != null } else window
|
||||
@@ -1313,7 +1332,7 @@ class PlaybackController(
|
||||
val current = ordered[index]
|
||||
val wantsLocal = downloads.localUri(current.item.song.id) != null
|
||||
val isLocal = player.itemUris().getOrNull(player.currentIndex)?.startsWith(FILE_SCHEME) == true
|
||||
if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.item.toQueueItem())
|
||||
if (wantsLocal != isLocal) player.replaceItem(player.currentIndex, current.playerItem(snapshot))
|
||||
windowPositions = LongArray(ordered.size) { ordered[it].position }
|
||||
windowFiltered = offline
|
||||
windowStart = ordered.first().position
|
||||
@@ -1323,8 +1342,8 @@ class PlaybackController(
|
||||
try {
|
||||
repeat(player.currentIndex) { player.removeFirst() }
|
||||
while (player.itemCount > 1) player.removeLast()
|
||||
for (i in index - 1 downTo 0) player.addFirst(ordered[i].item.toQueueItem())
|
||||
for (i in index + 1 until ordered.size) player.addLast(ordered[i].item.toQueueItem())
|
||||
for (i in index - 1 downTo 0) player.addFirst(ordered[i].playerItem(snapshot))
|
||||
for (i in index + 1 until ordered.size) player.addLast(ordered[i].playerItem(snapshot))
|
||||
} finally {
|
||||
updating = false
|
||||
}
|
||||
@@ -1466,6 +1485,17 @@ class PlaybackController(
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun QueueWindowItem.playerItem(snapshot: QueueSnapshot): QueueItem =
|
||||
item.toQueueItem(albumContext = snapshot.isAlbumContext(position))
|
||||
|
||||
private suspend fun queueItemAt(
|
||||
snapshot: QueueSnapshot,
|
||||
position: Long,
|
||||
): QueueItem? =
|
||||
queueRepository
|
||||
.itemAt(snapshot, position)
|
||||
?.toQueueItem(albumContext = snapshot.isAlbumContext(position))
|
||||
|
||||
private fun contextAt(position: Long?): QueueContext? {
|
||||
val snapshot = snapshot ?: return null
|
||||
val play = position?.let { snapshot.anchorContextPlay(it) } ?: return null
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package com.subtracks.playback
|
||||
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.SongItem
|
||||
|
||||
fun SongItem.toQueueItem() =
|
||||
fun SongItem.toQueueItem(albumContext: Boolean = false) =
|
||||
QueueItem(
|
||||
id = song.id,
|
||||
title = song.title,
|
||||
artist = song.artist,
|
||||
artists = song.artistRefs,
|
||||
album = song.album,
|
||||
coverArtId = coverArt,
|
||||
durationMs = song.duration?.let { it * 1000 },
|
||||
replayGain = song.replayGain(),
|
||||
albumContext = albumContext,
|
||||
)
|
||||
|
||||
@@ -29,20 +29,25 @@ internal fun Song.replayGain(): ReplayGain =
|
||||
internal fun replayGainLinearGain(
|
||||
gain: ReplayGain,
|
||||
settings: ReplayGainSettings,
|
||||
albumContext: Boolean = false,
|
||||
): Float {
|
||||
if (settings.mode == ReplayGainMode.Off) return 1f
|
||||
// gonic sends a peak of 0 when it only has album gain, so a non-positive or non-finite peak means
|
||||
// "no peak data" and is ignored rather than treated as silence.
|
||||
// "no peak data" and is ignored rather than treated as silence. A boost cannot be checked for
|
||||
// headroom without a peak, so a peakless track is only ever attenuated.
|
||||
val trackPeak = gain.trackPeak?.takeIf { it.isFinite() && it > 0f }
|
||||
val albumPeak = gain.albumPeak?.takeIf { it.isFinite() && it > 0f }
|
||||
val album = settings.mode == ReplayGainMode.Album || (settings.mode == ReplayGainMode.Auto && albumContext)
|
||||
val (requestedDb, peak) =
|
||||
when (settings.mode) {
|
||||
ReplayGainMode.Off -> return 1f
|
||||
ReplayGainMode.Track -> (gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
|
||||
ReplayGainMode.Album -> (gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
|
||||
if (album) {
|
||||
(gain.albumGain ?: gain.trackGain) to (albumPeak ?: trackPeak)
|
||||
} else {
|
||||
(gain.trackGain ?: gain.albumGain) to (trackPeak ?: albumPeak)
|
||||
}
|
||||
val gainDb = requestedDb?.takeIf { it.isFinite() } ?: return 1f
|
||||
val requested = gainDb + settings.preampDb
|
||||
val applied = peak?.let { min(requested, maxSafeGainDb(it)) } ?: requested
|
||||
val maxGainDb = peak?.let(::maxSafeGainDb) ?: 0f
|
||||
val applied = min(requested, maxGainDb)
|
||||
return 10f.pow(applied / 20f).takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ internal class ReplayGainController(
|
||||
private val apply: (Float) -> Unit,
|
||||
) {
|
||||
private var track = ReplayGain()
|
||||
private var settings = ReplayGainSettings(ReplayGainMode.Track, 0f)
|
||||
private var albumContext = false
|
||||
private var settings = ReplayGainSettings(ReplayGainMode.Auto, 0f)
|
||||
|
||||
@Synchronized
|
||||
fun applyItem(item: MediaItem) {
|
||||
track = item.replayGain()
|
||||
albumContext = item.albumContext()
|
||||
applyGain()
|
||||
}
|
||||
|
||||
@@ -22,6 +24,6 @@ internal class ReplayGainController(
|
||||
}
|
||||
|
||||
private fun applyGain() {
|
||||
apply(replayGainLinearGain(track, settings))
|
||||
apply(replayGainLinearGain(track, settings, albumContext))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,14 +61,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.AlbumInfoDialog
|
||||
import com.subtracks.ui.components.ArtistChooserSheet
|
||||
import com.subtracks.ui.components.ArtistInfoDialog
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.ItemContextMenu
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PlaylistInfoDialog
|
||||
import com.subtracks.ui.components.SongInfoDialog
|
||||
import com.subtracks.ui.components.statusBarScrim
|
||||
import com.subtracks.ui.downloads.DownloadsRoute
|
||||
@@ -230,7 +236,8 @@ private fun MainNavigation() {
|
||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val contextMenuHost = remember { ContextMenuHost() }
|
||||
var infoSong by remember { mutableStateOf<Song?>(null) }
|
||||
var infoTarget by remember { mutableStateOf<MenuTarget?>(null) }
|
||||
var artistChooser by remember { mutableStateOf<List<ArtistRef>?>(null) }
|
||||
|
||||
val activeSourceId by libraryRepository.activeSourceId.collectAsStateWithLifecycle(initialValue = null)
|
||||
var previousSourceId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||
@@ -312,7 +319,8 @@ private fun MainNavigation() {
|
||||
nowPlayingLauncher.openNowPlaying.collect {
|
||||
focusManager.clearFocus()
|
||||
contextMenuHost.dismissTransients()
|
||||
infoSong = null
|
||||
infoTarget = null
|
||||
artistChooser = null
|
||||
showingQueue = false
|
||||
if (navController.currentDestination?.route in DETAIL_ROUTES) {
|
||||
navController.popBackStack(Routes.LIBRARY, inclusive = false)
|
||||
@@ -375,7 +383,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onPlaylistClick = { playlist ->
|
||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||
@@ -418,7 +434,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onPlaylistClick = { playlist ->
|
||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||
@@ -514,7 +538,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||
onViewArtist = { artistId ->
|
||||
@@ -538,7 +570,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||
onViewArtist = { artistId ->
|
||||
@@ -562,7 +602,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
|
||||
onViewArtist = { artistId ->
|
||||
@@ -588,7 +636,15 @@ private fun MainNavigation() {
|
||||
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
|
||||
},
|
||||
onArtistClick = { artist ->
|
||||
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(
|
||||
artist.id,
|
||||
artist.coverArt ?: artist.coverArts.firstOrNull(),
|
||||
),
|
||||
)
|
||||
},
|
||||
onPlaylistClick = { playlist ->
|
||||
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
|
||||
@@ -725,23 +781,69 @@ private fun MainNavigation() {
|
||||
target = target,
|
||||
actions = contextMenuHost.actions,
|
||||
onDismiss = { contextMenuHost.dismiss() },
|
||||
onInfo = { infoSong = it },
|
||||
onInfo = { infoTarget = it },
|
||||
onChooseArtists = { artistChooser = it },
|
||||
)
|
||||
}
|
||||
|
||||
infoSong?.let { song ->
|
||||
val genres by
|
||||
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
SongInfoDialog(
|
||||
song = song,
|
||||
download = downloads[song.id],
|
||||
localFile = downloadRepository.localFile(song.id),
|
||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||
onDismiss = { infoSong = null },
|
||||
genres = genres,
|
||||
artistChooser?.let { artists ->
|
||||
ArtistChooserSheet(
|
||||
artists = artists,
|
||||
onArtistClick = { artist ->
|
||||
artistChooser = null
|
||||
navigateDetail(
|
||||
Routes.ARTIST_DETAIL,
|
||||
"artistId",
|
||||
artist.id,
|
||||
Routes.artist(artist.id),
|
||||
)
|
||||
},
|
||||
onDismiss = { artistChooser = null },
|
||||
)
|
||||
}
|
||||
|
||||
when (val target = infoTarget) {
|
||||
is MenuTarget.Song -> {
|
||||
val song = target.song
|
||||
val genres by
|
||||
remember(song.sourceId, song.id) { libraryRepository.songGenres(song.sourceId, song.id) }
|
||||
.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
SongInfoDialog(
|
||||
song = song,
|
||||
download = downloads[song.id],
|
||||
localFile = downloadRepository.localFile(song.id),
|
||||
streamEncoding = playbackController.currentAudioEncoding().takeIf { playback.item?.id == song.id },
|
||||
onDismiss = { infoTarget = null },
|
||||
genres = genres,
|
||||
)
|
||||
}
|
||||
|
||||
is MenuTarget.Album -> {
|
||||
AlbumInfoDialog(
|
||||
album = target.album,
|
||||
download = target.download,
|
||||
onDismiss = { infoTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
is MenuTarget.Artist -> {
|
||||
ArtistInfoDialog(
|
||||
artist = target.artist,
|
||||
onDismiss = { infoTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
is MenuTarget.Playlist -> {
|
||||
PlaylistInfoDialog(
|
||||
playlist = target.playlist,
|
||||
onDismiss = { infoTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
data class ArtistArt(
|
||||
val image: CoverArtRef? = null,
|
||||
val thumbnail: CoverArtRef? = null,
|
||||
val covers: List<CoverArtRef> = emptyList(),
|
||||
val fallback: ArtistArt? = null,
|
||||
) {
|
||||
val primary: CoverArtRef? get() = image ?: covers.firstOrNull()
|
||||
}
|
||||
|
||||
fun SourceRepository.artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt =
|
||||
resolveArtistArt(
|
||||
artist = artist,
|
||||
coverArt = this::coverArt,
|
||||
artistImage = this::artistImage,
|
||||
offline = offline.value,
|
||||
forIcon = forIcon,
|
||||
)
|
||||
|
||||
internal fun resolveArtistArt(
|
||||
artist: Artist,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistImage: (String, String?) -> CoverArtRef?,
|
||||
offline: Boolean,
|
||||
forIcon: Boolean,
|
||||
): ArtistArt {
|
||||
val coverIds = if (offline) artist.downloadedCoverArts else artist.coverArts
|
||||
val grid =
|
||||
ArtistArt(
|
||||
thumbnail = coverIds.singleOrNull()?.let { coverArt(it, true) },
|
||||
covers = coverIds.take(4).mapNotNull { coverArt(it, forIcon || coverIds.size > 1) },
|
||||
)
|
||||
val own =
|
||||
artist.coverArt?.let { coverId ->
|
||||
coverArt(coverId, forIcon)?.let { image ->
|
||||
ArtistArt(
|
||||
image = image,
|
||||
thumbnail = if (forIcon) image else coverArt(coverId, true),
|
||||
fallback = grid,
|
||||
)
|
||||
}
|
||||
}
|
||||
return artist.artistImageUrl
|
||||
?.let { artistImage(artist.id, it) }
|
||||
?.let { ArtistArt(image = it, thumbnail = it, fallback = own ?: grid) }
|
||||
?: own
|
||||
?: grid
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistIcon(
|
||||
artist: Artist,
|
||||
art: ArtistArt,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var shown by remember(artist.id, art) { mutableStateOf(art) }
|
||||
val showFallback = {
|
||||
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
|
||||
}
|
||||
when {
|
||||
shown.image != null -> {
|
||||
CoverArt(
|
||||
ref = shown.image,
|
||||
name = artist.name,
|
||||
modifier = modifier,
|
||||
onError = showFallback,
|
||||
)
|
||||
}
|
||||
|
||||
shown.covers.size >= 2 -> {
|
||||
ArtistArtGrid(covers = shown.covers, name = artist.name, modifier = modifier)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CoverArt(ref = shown.covers.firstOrNull(), name = artist.name, modifier = modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistHeroArt(
|
||||
art: ArtistArt,
|
||||
name: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var shown by remember(art) { mutableStateOf(art) }
|
||||
val showFallback = {
|
||||
shown.fallback?.let { shown = it } ?: run { shown = shown.copy(image = null) }
|
||||
}
|
||||
when {
|
||||
shown.image != null -> {
|
||||
CoverArt(
|
||||
ref = shown.image,
|
||||
name = name,
|
||||
thumbnailRef = shown.thumbnail,
|
||||
showPlaceholder = false,
|
||||
modifier = modifier,
|
||||
onError = showFallback,
|
||||
)
|
||||
}
|
||||
|
||||
shown.covers.isNotEmpty() -> {
|
||||
ArtistArtGrid(
|
||||
covers = shown.covers,
|
||||
name = name,
|
||||
singleThumbnail = shown.thumbnail,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CoverArt(ref = null, name = name, modifier = modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistArtGrid(
|
||||
covers: List<CoverArtRef>,
|
||||
name: String,
|
||||
modifier: Modifier = Modifier,
|
||||
singleThumbnail: CoverArtRef? = null,
|
||||
) {
|
||||
when {
|
||||
covers.size >= 4 -> {
|
||||
Column(modifier) {
|
||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||
covers.take(2).forEach { ref ->
|
||||
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
Row(Modifier.weight(1f).fillMaxWidth()) {
|
||||
covers.drop(2).take(2).forEach { ref ->
|
||||
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
covers.size == 3 -> {
|
||||
Box(modifier) {
|
||||
covers.forEachIndexed { index, ref ->
|
||||
CoverArt(
|
||||
ref = ref,
|
||||
name = name,
|
||||
modifier = Modifier.matchParentSize().clip(YRegionShape(index)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
covers.size == 2 -> {
|
||||
Row(modifier) {
|
||||
covers.forEach { ref ->
|
||||
CoverArt(ref = ref, name = name, modifier = Modifier.weight(1f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
covers.size == 1 -> {
|
||||
CoverArt(
|
||||
ref = covers.first(),
|
||||
name = name,
|
||||
thumbnailRef = singleThumbnail,
|
||||
showPlaceholder = false,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class YRegionShape(
|
||||
private val region: Int,
|
||||
) : Shape {
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density,
|
||||
): Outline {
|
||||
val center = Offset(size.width / 2f, size.height / 2f)
|
||||
val left = rayPoint(size, 150.0)
|
||||
val right = rayPoint(size, 30.0)
|
||||
val top = Offset(size.width / 2f, 0f)
|
||||
val path = Path()
|
||||
when (region) {
|
||||
0 -> {
|
||||
path.moveTo(center.x, center.y)
|
||||
path.lineTo(right.x, right.y)
|
||||
addClockwise(path, size, right, left)
|
||||
}
|
||||
|
||||
1 -> {
|
||||
path.moveTo(center.x, center.y)
|
||||
path.lineTo(left.x, left.y)
|
||||
addClockwise(path, size, left, top)
|
||||
}
|
||||
|
||||
else -> {
|
||||
path.moveTo(center.x, center.y)
|
||||
path.lineTo(top.x, top.y)
|
||||
addClockwise(path, size, top, right)
|
||||
}
|
||||
}
|
||||
path.lineTo(center.x, center.y)
|
||||
path.close()
|
||||
return Outline.Generic(path)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rayPoint(
|
||||
size: Size,
|
||||
angleDegrees: Double,
|
||||
): Offset {
|
||||
val cx = size.width / 2.0
|
||||
val cy = size.height / 2.0
|
||||
val radians = angleDegrees * PI / 180.0
|
||||
val dx = cos(radians)
|
||||
val dy = sin(radians)
|
||||
val tx =
|
||||
when {
|
||||
dx > 0 -> (size.width - cx) / dx
|
||||
dx < 0 -> -cx / dx
|
||||
else -> Double.POSITIVE_INFINITY
|
||||
}
|
||||
val ty =
|
||||
when {
|
||||
dy > 0 -> (size.height - cy) / dy
|
||||
dy < 0 -> -cy / dy
|
||||
else -> Double.POSITIVE_INFINITY
|
||||
}
|
||||
val t = min(tx, ty)
|
||||
return Offset((cx + dx * t).toFloat(), (cy + dy * t).toFloat())
|
||||
}
|
||||
|
||||
private fun addClockwise(
|
||||
path: Path,
|
||||
size: Size,
|
||||
from: Offset,
|
||||
to: Offset,
|
||||
) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val corners =
|
||||
listOf(
|
||||
Offset(0f, 0f),
|
||||
Offset(w, 0f),
|
||||
Offset(w, h),
|
||||
Offset(0f, h),
|
||||
)
|
||||
val fromParam = perimeterParam(from, size)
|
||||
val toParam = perimeterParam(to, size)
|
||||
if (fromParam <= toParam) {
|
||||
corners
|
||||
.filter { perimeterParam(it, size) > fromParam && perimeterParam(it, size) < toParam }
|
||||
.forEach { path.lineTo(it.x, it.y) }
|
||||
} else {
|
||||
corners
|
||||
.filter { perimeterParam(it, size) > fromParam }
|
||||
.forEach { path.lineTo(it.x, it.y) }
|
||||
corners
|
||||
.filter { perimeterParam(it, size) < toParam }
|
||||
.forEach { path.lineTo(it.x, it.y) }
|
||||
}
|
||||
path.lineTo(to.x, to.y)
|
||||
}
|
||||
|
||||
private fun perimeterParam(
|
||||
point: Offset,
|
||||
size: Size,
|
||||
): Float {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
return when {
|
||||
point.y <= 0f -> point.x
|
||||
point.x >= w -> w + point.y
|
||||
point.y >= h -> w + h + (w - point.x)
|
||||
else -> 2f * w + h + (h - point.y)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
|
||||
const val ARTIST_CHOOSER_TAG = "artistChooser"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ArtistChooserSheet(
|
||||
artists: List<ArtistRef>,
|
||||
onArtistClick: (ArtistRef) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
dragHandle = null,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier.testTag(ARTIST_CHOOSER_TAG),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.artist_chooser_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||
)
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 400.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
artists.forEach { artist ->
|
||||
ListItem(
|
||||
headlineContent = { Text(artist.name.ifBlank { stringResource(R.string.resources_filter_artist) }) },
|
||||
leadingContent = { Icon(Icons.Rounded.Person, contentDescription = null) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable { onArtistClick(artist) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
@@ -85,12 +85,15 @@ fun CoverArt(
|
||||
square: Boolean = true,
|
||||
elevation: Dp = 0.dp,
|
||||
colorFilter: ColorFilter? = null,
|
||||
onError: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalPlatformContext.current
|
||||
val refKey = ref?.cacheKey
|
||||
val thumbnailKey = thumbnailRef?.cacheKey
|
||||
val hasThumbnailFallback = thumbnailRef != null && thumbnailRef != ref
|
||||
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||
var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||
var thumbnailFailed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
|
||||
val letterScheme =
|
||||
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
||||
@@ -115,6 +118,10 @@ fun CoverArt(
|
||||
thumbnailLoaded = thumbnailLoaded,
|
||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||
onThumbnailRatio = { thumbnailRatio = it },
|
||||
onThumbnailError = {
|
||||
thumbnailFailed = true
|
||||
if (failed) onError?.invoke()
|
||||
},
|
||||
contentScale = ContentScale.Crop,
|
||||
colorFilter = colorFilter,
|
||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||
@@ -131,7 +138,10 @@ fun CoverArt(
|
||||
.ratioOrNull()
|
||||
?.let { ArtworkRatioCache.put(ref.cacheKey, it) }
|
||||
},
|
||||
onError = { failed = true },
|
||||
onError = {
|
||||
failed = true
|
||||
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
@@ -160,7 +170,10 @@ fun CoverArt(
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { failed = true },
|
||||
onError = {
|
||||
failed = true
|
||||
if (!hasThumbnailFallback || thumbnailFailed) onError?.invoke()
|
||||
},
|
||||
contentScale = ContentScale.Fit,
|
||||
filterQuality = FilterQuality.High,
|
||||
)
|
||||
@@ -179,6 +192,10 @@ fun CoverArt(
|
||||
thumbnailLoaded = thumbnailLoaded,
|
||||
onThumbnailLoaded = { thumbnailLoaded = true },
|
||||
onThumbnailRatio = { thumbnailRatio = it },
|
||||
onThumbnailError = {
|
||||
thumbnailFailed = true
|
||||
if (failed) onError?.invoke()
|
||||
},
|
||||
contentScale = ContentScale.Fit,
|
||||
colorFilter = colorFilter,
|
||||
placeholderContentColor = letterScheme?.onPrimaryContainer,
|
||||
@@ -208,6 +225,7 @@ private fun BoxScope.CoverArtContent(
|
||||
thumbnailLoaded: Boolean,
|
||||
onThumbnailLoaded: () -> Unit,
|
||||
onThumbnailRatio: (Float) -> Unit,
|
||||
onThumbnailError: () -> Unit,
|
||||
contentScale: ContentScale,
|
||||
colorFilter: ColorFilter?,
|
||||
placeholderContentColor: Color?,
|
||||
@@ -246,6 +264,7 @@ private fun BoxScope.CoverArtContent(
|
||||
ArtworkRatioCache.put(thumbnailRef.cacheKey, it)
|
||||
}
|
||||
},
|
||||
onError = { onThumbnailError() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -257,6 +289,7 @@ fun HeroHeader(
|
||||
thumbnailRef: CoverArtRef? = null,
|
||||
comment: String? = null,
|
||||
onSubtitleClick: (() -> Unit)? = null,
|
||||
nameMaxLines: Int = 2,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
@@ -278,7 +311,7 @@ fun HeroHeader(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
maxLines = nameMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitleIsTarget = onSubtitleClick != null && subtitle.isNotEmpty() && comment.isNullOrBlank()
|
||||
@@ -290,7 +323,11 @@ fun HeroHeader(
|
||||
modifier =
|
||||
Modifier
|
||||
.heightIn(min = 32.dp)
|
||||
.clickable(onClickLabel = stringResource(R.string.open_artist)) { onSubtitleClick() },
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClickLabel = stringResource(R.string.open_artist),
|
||||
) { onSubtitleClick() },
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
HeroSubtitle(subtitle, Modifier.padding(top = 4.dp))
|
||||
|
||||
@@ -6,12 +6,16 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.rounded.QueueMusic
|
||||
import androidx.compose.material.icons.rounded.Album
|
||||
import androidx.compose.material.icons.rounded.Cancel
|
||||
@@ -47,6 +51,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadList
|
||||
@@ -77,7 +82,7 @@ sealed interface MenuTarget {
|
||||
) : MenuTarget {
|
||||
override val title: String get() = song.title
|
||||
|
||||
override val subtitle: String? get() = song.artist
|
||||
override val subtitle: String? get() = song.artistSubtitle()
|
||||
}
|
||||
|
||||
data class Album(
|
||||
@@ -194,7 +199,8 @@ fun ItemContextMenu(
|
||||
target: MenuTarget,
|
||||
actions: ItemActions,
|
||||
onDismiss: () -> Unit,
|
||||
onInfo: ((SongModel) -> Unit)? = null,
|
||||
onInfo: ((MenuTarget) -> Unit)? = null,
|
||||
onChooseArtists: ((List<ArtistRef>) -> Unit)? = null,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -207,6 +213,11 @@ fun ItemContextMenu(
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceWith(after: () -> Unit) {
|
||||
onDismiss()
|
||||
after()
|
||||
}
|
||||
|
||||
val queueRef = target.queueRef()
|
||||
|
||||
@Composable
|
||||
@@ -226,76 +237,75 @@ fun ItemContextMenu(
|
||||
dragHandle = null,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
MenuHeader(target)
|
||||
when (target) {
|
||||
is MenuTarget.Song -> {
|
||||
queueItems()
|
||||
DownloadItem(target.download, target.song, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.song.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
target.song.albumId?.let { albumId ->
|
||||
actions.viewAlbum?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Album,
|
||||
target.song.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_album),
|
||||
) {
|
||||
dismiss { view(albumId) }
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = MENU_MAX_HEIGHT)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
MenuHeader(target)
|
||||
when (target) {
|
||||
is MenuTarget.Song -> {
|
||||
queueItems()
|
||||
DownloadItem(target.download, target.song, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.song.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Song, target.song.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
target.song.albumId?.let { albumId ->
|
||||
actions.viewAlbum?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Album,
|
||||
target.song.album?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_album),
|
||||
) {
|
||||
dismiss { view(albumId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
target.song.artistId?.let { artistId ->
|
||||
actions.viewArtist?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
target.song.artist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artistId) }
|
||||
}
|
||||
ArtistItems(target.song.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target.song) } }
|
||||
}
|
||||
}
|
||||
|
||||
is MenuTarget.Album -> {
|
||||
queueItems()
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.album.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
target.album.artistId?.let { artistId ->
|
||||
actions.viewArtist?.let { view ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
target.album.albumArtist?.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artistId) }
|
||||
}
|
||||
is MenuTarget.Album -> {
|
||||
queueItems()
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.album.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
ArtistItems(target.album.artistRefs, actions, onChooseArtists, ::dismiss, ::replaceWith)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
|
||||
is MenuTarget.Artist -> {
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.artist.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
|
||||
is MenuTarget.Playlist -> {
|
||||
queueItems()
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
onInfo?.let { info ->
|
||||
MenuItem(Icons.Rounded.Info, stringResource(R.string.info)) { dismiss { info(target) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is MenuTarget.Artist -> {
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.artist.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
||||
onDismiss = { dismiss() },
|
||||
)
|
||||
}
|
||||
|
||||
is MenuTarget.Playlist -> {
|
||||
queueItems()
|
||||
BulkDownloadItem(target, actions, ::dismiss)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +341,55 @@ private fun MenuHeader(target: MenuTarget) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistItems(
|
||||
artists: List<ArtistRef>,
|
||||
actions: ItemActions,
|
||||
onChooseArtists: ((List<ArtistRef>) -> Unit)?,
|
||||
dismiss: (() -> Unit) -> Unit,
|
||||
replaceWith: (() -> Unit) -> Unit,
|
||||
) {
|
||||
val view = actions.viewArtist ?: return
|
||||
val distinct = artists.distinctBy { it.id }
|
||||
when (distinct.size) {
|
||||
0 -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
1 -> {
|
||||
val artist = distinct.first()
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (onChooseArtists == null) {
|
||||
distinct.forEach { artist ->
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
artist.name.takeIf { it.isNotBlank() } ?: stringResource(R.string.resources_filter_artist),
|
||||
) {
|
||||
dismiss { view(artist.id) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val fallback = stringResource(R.string.resources_filter_artist)
|
||||
MenuItem(
|
||||
Icons.Rounded.Person,
|
||||
distinct.joinToString(" \u00B7 ") { it.name.ifBlank { fallback } },
|
||||
trailing = { Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null) },
|
||||
) {
|
||||
replaceWith { onChooseArtists(distinct) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadItem(
|
||||
download: SongDownload?,
|
||||
@@ -428,11 +487,19 @@ private fun MenuItem(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
tint: Color? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(label) },
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
leadingContent = { Icon(icon, contentDescription = null, tint = tint ?: LocalContentColor.current) },
|
||||
trailingContent = trailing,
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
)
|
||||
@@ -445,3 +512,4 @@ private fun starLabel(starred: Long?): String =
|
||||
if (starred == null) stringResource(R.string.actions_star) else stringResource(R.string.actions_unstar)
|
||||
|
||||
private const val STAR_DISMISS_DELAY_MS = 200L
|
||||
private val MENU_MAX_HEIGHT = 560.dp
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
|
||||
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||
|
||||
@Composable
|
||||
fun AlbumInfoDialog(
|
||||
album: Album,
|
||||
download: ListDownloadStatus?,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(stringResource(R.string.resources_sort_by_title), album.name)
|
||||
InfoRow(stringResource(R.string.info_sort_title), album.sortName.takeIfDifferentFrom(album.name))
|
||||
InfoRow(stringResource(R.string.info_album_artists), album.artistRefs.names())
|
||||
InfoRow(stringResource(R.string.info_album_artist_sort), album.albumArtistSort.takeIfDifferentFrom(album.albumArtist))
|
||||
InfoRow(stringResource(R.string.resources_sort_by_year), album.year?.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.resources_filter_genre), album.genre)
|
||||
InfoRow(stringResource(R.string.info_songs), album.songCount.takeIf { it > 0 }?.toString())
|
||||
download?.takeIf { it.total > 0 }?.let { status ->
|
||||
InfoRow(
|
||||
stringResource(R.string.status_downloaded),
|
||||
stringResource(R.string.info_downloaded_count, status.downloaded, status.total),
|
||||
)
|
||||
}
|
||||
InfoRow(stringResource(R.string.info_added), album.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_last_played), album.played?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_plays), album.playCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_starred), if (album.starred != null) stringResource(R.string.info_yes) else null)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArtistInfoDialog(
|
||||
artist: Artist,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(stringResource(R.string.resources_filter_artist), artist.name)
|
||||
InfoRow(stringResource(R.string.info_sort_name), artist.sortName.takeIfDifferentFrom(artist.name))
|
||||
InfoRow(stringResource(R.string.info_albums), artist.albumCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_songs), artist.songCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_plays), artist.playCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_last_played), artist.played?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_starred), if (artist.starred != null) stringResource(R.string.info_yes) else null)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlaylistInfoDialog(
|
||||
playlist: Playlist,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
InfoRow(stringResource(R.string.resources_sort_by_title), playlist.name)
|
||||
InfoRow(stringResource(R.string.info_owner), playlist.owner)
|
||||
InfoRow(stringResource(R.string.info_shared), if (playlist.shared) stringResource(R.string.info_yes) else null)
|
||||
InfoRow(stringResource(R.string.info_songs), playlist.songCount.takeIf { it > 0 }?.toString())
|
||||
InfoRow(stringResource(R.string.info_length), playlist.duration.takeIf { it > 0 }?.let(DateUtils::formatElapsedTime))
|
||||
InfoRow(stringResource(R.string.info_created), playlist.created.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||
InfoRow(stringResource(R.string.info_updated), playlist.changed.takeIf { it > 0 }?.let { formatDate(context, it) })
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.info_close)) } },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun String?.takeIfDifferentFrom(regular: String?): String? =
|
||||
this?.trim()?.takeIf { value -> value.isNotEmpty() && !value.equals(regular?.trim(), ignoreCase = true) }
|
||||
|
||||
@Composable
|
||||
internal fun InfoRow(
|
||||
label: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value.isNullOrBlank()) return
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
||||
)
|
||||
Spacer(Modifier.width(INFO_LABEL_GAP))
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<ArtistRef>.names(): String? =
|
||||
map { it.name }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.joinToString(ARTIST_SEPARATOR)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun formatDate(
|
||||
context: Context,
|
||||
epochSeconds: Long,
|
||||
): String =
|
||||
DateUtils.formatDateTime(
|
||||
context,
|
||||
epochSeconds * 1000,
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR,
|
||||
)
|
||||
|
||||
private val INFO_LABEL_WIDTH = 100.dp
|
||||
private val INFO_LABEL_GAP = 12.dp
|
||||
@@ -20,6 +20,10 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
@@ -28,6 +32,8 @@ import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.R
|
||||
@@ -43,6 +49,12 @@ fun SearchField(
|
||||
) {
|
||||
val hint = stringResource(R.string.search_input_placeholder)
|
||||
val closeLabel = stringResource(R.string.close_search)
|
||||
var textFieldValue by remember { mutableStateOf(TextFieldValue(value, selection = TextRange(value.length))) }
|
||||
LaunchedEffect(value) {
|
||||
if (value != textFieldValue.text) {
|
||||
textFieldValue = TextFieldValue(value, selection = TextRange(value.length))
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
@@ -62,8 +74,11 @@ fun SearchField(
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
value = textFieldValue,
|
||||
onValueChange = {
|
||||
textFieldValue = it
|
||||
onValueChange(it.text)
|
||||
},
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
@@ -73,7 +88,7 @@ fun SearchField(
|
||||
.focusRequester(focusRequester)
|
||||
.semantics { contentDescription = hint },
|
||||
decorationBox = { inner ->
|
||||
if (value.isEmpty()) {
|
||||
if (textFieldValue.text.isEmpty()) {
|
||||
Text(
|
||||
text = hint,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
import com.subtracks.data.model.Song
|
||||
|
||||
private const val ARTIST_SEPARATOR = " \u00B7 "
|
||||
|
||||
internal fun Song.artistSubtitle(): String? = artistSubtitle(artist, artists)
|
||||
|
||||
internal fun Song.artistNames(): List<String> = artistNames(artist, artists)
|
||||
|
||||
internal fun artistNames(
|
||||
artist: String?,
|
||||
artists: List<ArtistRef>,
|
||||
): List<String> {
|
||||
val names = artists.map { it.name }.filter { it.isNotBlank() }.distinct()
|
||||
return names.ifEmpty { listOfNotNull(artist?.takeIf { it.isNotBlank() }) }
|
||||
}
|
||||
|
||||
internal fun artistSubtitle(
|
||||
artist: String?,
|
||||
artists: List<ArtistRef>,
|
||||
): String? {
|
||||
val names = artistNames(artist, artists)
|
||||
if (names.isEmpty()) return null
|
||||
val primary = artist?.takeIf { it in names }
|
||||
return buildList {
|
||||
primary?.let(::add)
|
||||
addAll(names.filterNot { it == primary })
|
||||
}.joinToString(ARTIST_SEPARATOR)
|
||||
}
|
||||
@@ -4,12 +4,7 @@ import android.text.format.DateUtils
|
||||
import android.text.format.Formatter
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -18,8 +13,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -53,8 +46,20 @@ fun SongInfoDialog(
|
||||
title = { Text(stringResource(R.string.info)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val multipleArtists = song.artistNames().size > 1
|
||||
val regularArtistSort = song.artist?.takeIf { it.isNotBlank() } ?: song.artistRefs.firstOrNull()?.name
|
||||
InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
|
||||
InfoRow(stringResource(R.string.resources_filter_artist), song.artist)
|
||||
InfoRow(stringResource(R.string.info_sort_title), song.sortName.takeIfDifferentFrom(song.title))
|
||||
InfoRow(
|
||||
stringResource(
|
||||
if (multipleArtists) R.string.info_artists else R.string.resources_filter_artist,
|
||||
),
|
||||
song.artistSubtitle(),
|
||||
)
|
||||
InfoRow(
|
||||
stringResource(if (multipleArtists) R.string.info_artists_sort else R.string.info_artist_sort),
|
||||
song.artistSort.takeIfDifferentFrom(regularArtistSort),
|
||||
)
|
||||
InfoRow(stringResource(R.string.resources_filter_album), song.album)
|
||||
InfoRow(stringResource(R.string.info_duration), song.duration?.let(DateUtils::formatElapsedTime))
|
||||
InfoRow(
|
||||
@@ -92,32 +97,6 @@ fun SongInfoDialog(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(
|
||||
label: String,
|
||||
value: String?,
|
||||
) {
|
||||
if (value.isNullOrBlank()) return
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.width(INFO_LABEL_WIDTH),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val INFO_LABEL_WIDTH = 80.dp
|
||||
|
||||
@Composable
|
||||
private fun downloadStatus(status: DownloadStatus): String =
|
||||
when (status) {
|
||||
|
||||
@@ -13,10 +13,10 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.ExpandLess
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -274,7 +274,7 @@ private fun NodeRow(
|
||||
leadingContent = {
|
||||
IconButton(onClick = onToggle) {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
|
||||
imageVector = if (expanded) Icons.Rounded.KeyboardArrowDown else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
contentDescription = if (expanded) stringResource(R.string.collapse) else stringResource(R.string.expand),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ data class DownloadArtistNode(
|
||||
val name: String,
|
||||
val bytes: Long,
|
||||
val albums: List<DownloadAlbumNode>,
|
||||
val sortName: String = name,
|
||||
)
|
||||
|
||||
data class DownloadAlbumNode(
|
||||
@@ -38,6 +39,7 @@ data class DownloadAlbumNode(
|
||||
val name: String,
|
||||
val bytes: Long,
|
||||
val songs: List<DownloadedSong>,
|
||||
val sortName: String = name,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -105,6 +107,7 @@ internal fun buildTree(
|
||||
id = artistKey.first,
|
||||
name = artistKey.second.ifBlank { unknownArtist },
|
||||
bytes = artistSongs.sumOf { it.size },
|
||||
sortName = artistSongs.first().artistSort.ifBlank { artistKey.second },
|
||||
albums =
|
||||
artistSongs
|
||||
.groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() }
|
||||
@@ -113,9 +116,10 @@ internal fun buildTree(
|
||||
id = albumKey.first,
|
||||
name = albumKey.second.ifBlank { unknownAlbum },
|
||||
bytes = albumSongs.sumOf { it.size },
|
||||
songs = albumSongs.sortedBy { it.title.lowercase() },
|
||||
sortName = albumSongs.first().albumSort.ifBlank { albumKey.second },
|
||||
songs = albumSongs,
|
||||
)
|
||||
}.sortedBy { it.name.lowercase() },
|
||||
}.sortedBy { it.sortName.lowercase() },
|
||||
)
|
||||
}.sortedBy { it.name.lowercase() },
|
||||
}.sortedBy { it.sortName.lowercase() },
|
||||
)
|
||||
|
||||
@@ -57,6 +57,8 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ArtistIcon
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
@@ -108,6 +110,7 @@ fun HomeListRoute(
|
||||
genres = genres,
|
||||
decades = decades,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
onBack = onBack,
|
||||
onAlbumClick = onAlbumClick,
|
||||
@@ -133,6 +136,7 @@ fun HomeListScreen(
|
||||
genres: List<String> = emptyList(),
|
||||
decades: List<Long> = emptyList(),
|
||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
playingSongId: String? = null,
|
||||
onBack: () -> Unit = {},
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -191,7 +195,7 @@ fun HomeListScreen(
|
||||
OfflineListKind.Artists -> {
|
||||
ArtistsList(
|
||||
artists = artists,
|
||||
coverArt = coverArt,
|
||||
artistArt = artistArt,
|
||||
info = { artistListInfo(request, it, now, resources) },
|
||||
onArtistClick = onArtistClick,
|
||||
onLongClick = onLongClick,
|
||||
@@ -245,7 +249,7 @@ fun HomeListScreen(
|
||||
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
|
||||
ArtistsList(
|
||||
artists = artists,
|
||||
coverArt = coverArt,
|
||||
artistArt = artistArt,
|
||||
info = { artistListInfo(request, it, now, resources) },
|
||||
onArtistClick = onArtistClick,
|
||||
onLongClick = onLongClick,
|
||||
@@ -443,7 +447,7 @@ internal fun AlbumListRow(
|
||||
@Composable
|
||||
private fun ArtistsList(
|
||||
artists: LazyPagingItems<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
info: (Artist) -> String?,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
@@ -460,12 +464,13 @@ private fun ArtistsList(
|
||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||
val artist = artists[index]
|
||||
if (artist != null) {
|
||||
val art = artistArt(artist)
|
||||
ArtistListRow(
|
||||
artist = artist,
|
||||
coverArt = coverArt,
|
||||
art = art,
|
||||
info = info(artist),
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -475,7 +480,7 @@ private fun ArtistsList(
|
||||
@Composable
|
||||
internal fun ArtistListRow(
|
||||
artist: Artist,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
art: ArtistArt,
|
||||
info: String?,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
@@ -488,9 +493,9 @@ internal fun ArtistListRow(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = coverArt(artist.coverArt, true),
|
||||
name = artist.name,
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
art = art,
|
||||
modifier = Modifier.size(ALBUM_ROW_COVER).clip(CircleShape),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
@@ -16,6 +16,8 @@ import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -156,6 +158,11 @@ class HomeListViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||
|
||||
fun play(song: Song) {
|
||||
val genre = request.genre
|
||||
when {
|
||||
|
||||
@@ -72,6 +72,8 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ArtistIcon
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
@@ -137,6 +139,7 @@ fun HomeRoute(
|
||||
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
|
||||
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
topInset = topInset,
|
||||
bottomInset = bottomInset,
|
||||
@@ -152,6 +155,7 @@ fun HomeRoute(
|
||||
HomeScreen(
|
||||
feed = feed,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
topInset = topInset,
|
||||
bottomInset = bottomInset,
|
||||
@@ -173,6 +177,7 @@ fun HomeScreen(
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
playingSongId: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
topInset: Dp = 0.dp,
|
||||
bottomInset: Dp = 0.dp,
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -215,6 +220,7 @@ fun HomeScreen(
|
||||
HomeSection.RecentlyPlayedArtists,
|
||||
feed.recentlyPlayedArtists,
|
||||
coverArt,
|
||||
artistArt,
|
||||
onArtistClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
@@ -249,6 +255,7 @@ fun HomeScreen(
|
||||
HomeSection.MostPlayedArtists,
|
||||
feed.mostPlayedArtists,
|
||||
coverArt,
|
||||
artistArt,
|
||||
onArtistClick,
|
||||
onLongClick,
|
||||
onMore,
|
||||
@@ -296,6 +303,7 @@ fun OfflineHomeScreen(
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
playingSongId: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
topInset: Dp = 0.dp,
|
||||
bottomInset: Dp = 0.dp,
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -312,7 +320,7 @@ fun OfflineHomeScreen(
|
||||
) {
|
||||
homeTitle()
|
||||
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
|
||||
downloadedArtistRow(artists, coverArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
|
||||
downloadedArtistRow(artists, artistArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
|
||||
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
|
||||
songList(
|
||||
key = "downloaded-songs",
|
||||
@@ -364,7 +372,7 @@ private fun LazyListScope.downloadedAlbumRow(
|
||||
|
||||
private fun LazyListScope.downloadedArtistRow(
|
||||
artists: LazyPagingItems<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
onMore: () -> Unit,
|
||||
@@ -382,12 +390,12 @@ private fun LazyListScope.downloadedArtistRow(
|
||||
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
|
||||
val artist = artists[index]
|
||||
if (artist != null) {
|
||||
val ref = coverArt(artist.coverArt, true)
|
||||
val art = artistArt(artist)
|
||||
ArtistTile(
|
||||
artist = artist,
|
||||
ref = ref,
|
||||
art = art,
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -607,6 +615,7 @@ private fun LazyListScope.artistRow(
|
||||
section: HomeSection,
|
||||
artists: List<Artist>,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
onArtistClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
onMore: (HomeSection) -> Unit,
|
||||
@@ -625,12 +634,12 @@ private fun LazyListScope.artistRow(
|
||||
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
|
||||
) {
|
||||
items(artists, key = { it.id }) { artist ->
|
||||
val ref = coverArt(artist.coverArt, true)
|
||||
val art = artistArt(artist)
|
||||
ArtistTile(
|
||||
artist = artist,
|
||||
ref = ref,
|
||||
art = art,
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -779,7 +788,7 @@ private fun AlbumTile(
|
||||
@Composable
|
||||
private fun ArtistTile(
|
||||
artist: Artist,
|
||||
ref: CoverArtRef?,
|
||||
art: ArtistArt,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
) {
|
||||
@@ -787,9 +796,9 @@ private fun ArtistTile(
|
||||
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CoverArt(
|
||||
ref = ref,
|
||||
name = artist.name,
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
art = art,
|
||||
modifier = Modifier.size(ARTIST_TILE_SIZE).clip(CircleShape),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
@@ -17,6 +17,8 @@ import com.subtracks.data.repo.rediscoverCutoff
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.data.sync.SyncStatus
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -127,6 +129,11 @@ class HomeViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||
|
||||
fun playStarred(song: Song) {
|
||||
viewModelScope.playStarredList(libraryRepository, playbackController, song)
|
||||
}
|
||||
@@ -213,7 +220,7 @@ internal fun CoroutineScope.playGenreList(
|
||||
song: Song,
|
||||
) {
|
||||
launch {
|
||||
val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.title, song.id)
|
||||
val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.sortName, song.id)
|
||||
playbackController.playGenre(song.sourceId, genre, ordinal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,24 +29,26 @@ 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
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistChooserSheet
|
||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
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
|
||||
@@ -66,24 +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) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||
val dismissals = contextMenuHost?.dismissals ?: emptyFlow()
|
||||
val pendingDelete = rememberPendingDownloadDelete(dismissals) { _, _ -> viewModel.downloadedBytes() }
|
||||
var showArtistChooser by remember { mutableStateOf(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 =
|
||||
@@ -126,18 +126,28 @@ fun AlbumDetailRoute(
|
||||
},
|
||||
onArtistClick =
|
||||
album
|
||||
?.takeIf { !it.albumArtist.isNullOrBlank() }
|
||||
?.let { it.artistId }
|
||||
?.let { id -> { onViewArtist(id) } },
|
||||
?.takeIf { it.artistRefs.isNotEmpty() }
|
||||
?.let {
|
||||
{
|
||||
val artists = it.artistRefs
|
||||
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
|
||||
}
|
||||
},
|
||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
||||
)
|
||||
|
||||
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) {
|
||||
ArtistChooserSheet(
|
||||
artists = album?.artistRefs.orEmpty(),
|
||||
onArtistClick = { artist ->
|
||||
showArtistChooser = false
|
||||
onViewArtist(artist.id)
|
||||
},
|
||||
onDismiss = { showArtistChooser = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -195,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(
|
||||
@@ -215,6 +226,7 @@ fun AlbumDetailScreen(
|
||||
onMore = onMore,
|
||||
topInset = topInset,
|
||||
controlsModifier = controlsModifier,
|
||||
nameMaxLines = Int.MAX_VALUE,
|
||||
)
|
||||
},
|
||||
content = { rowModifier ->
|
||||
|
||||
@@ -15,7 +15,6 @@ import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
@@ -35,13 +34,20 @@ class AlbumDetailViewModel(
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val album: Flow<Album?> = sourceId.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||
val album: StateFlow<Album?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.album(it, albumId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val songs: Flow<List<Song>> =
|
||||
val songs: StateFlow<List<Song>> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
.flatMapLatest { (id, offline) -> libraryRepository.albumSongs(id, albumId, offline) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val discs: Flow<List<Disc>> = sourceId.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||
val discs: StateFlow<List<Disc>> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.albumDiscs(it, albumId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,9 @@ package com.subtracks.ui.library
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistAlbums
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadList
|
||||
@@ -12,9 +13,10 @@ import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -36,21 +38,26 @@ class ArtistDetailViewModel(
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val artist: Flow<Artist?> = sourceId.flatMapLatest { libraryRepository.artist(it, artistId) }
|
||||
val artist: StateFlow<Artist?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.artist(it, artistId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val artistName: Flow<String?> =
|
||||
sourceId.flatMapLatest { id ->
|
||||
combine(
|
||||
libraryRepository.artist(id, artistId),
|
||||
libraryRepository.artistName(id, artistId),
|
||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
||||
}
|
||||
val artistName: StateFlow<String?> =
|
||||
sourceId
|
||||
.flatMapLatest { id ->
|
||||
combine(
|
||||
libraryRepository.artist(id, artistId),
|
||||
libraryRepository.artistName(id, artistId),
|
||||
) { loaded, fromSongs -> loaded?.name?.takeIf { it.isNotBlank() } ?: fromSongs }
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||
|
||||
val albums: Flow<List<Album>> =
|
||||
val albums: StateFlow<ArtistAlbums?> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||
sourceId
|
||||
@@ -62,6 +69,55 @@ class ArtistDetailViewModel(
|
||||
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||
|
||||
val artistBio: StateFlow<ArtistBio?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.artistBio(it, artistId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
private val _bioLoading = MutableStateFlow(true)
|
||||
val bioLoading: StateFlow<Boolean> = _bioLoading
|
||||
|
||||
private val _bioUnavailable = MutableStateFlow(false)
|
||||
val bioUnavailable: StateFlow<Boolean> = _bioUnavailable
|
||||
|
||||
private val _bioExpanded = MutableStateFlow(false)
|
||||
val bioExpanded: StateFlow<Boolean> = _bioExpanded
|
||||
|
||||
private var bioRefresh: Job? = null
|
||||
|
||||
fun refreshBio() {
|
||||
if (bioRefresh?.isActive == true) return
|
||||
bioRefresh =
|
||||
viewModelScope.launch {
|
||||
_bioUnavailable.value = false
|
||||
if (sourceRepository.offline.value) {
|
||||
_bioLoading.value = false
|
||||
_bioUnavailable.value = true
|
||||
return@launch
|
||||
}
|
||||
val id = sourceRepository.activeSourceIdOnce()
|
||||
if (id == null) {
|
||||
_bioLoading.value = false
|
||||
_bioUnavailable.value = true
|
||||
return@launch
|
||||
}
|
||||
_bioLoading.value = true
|
||||
try {
|
||||
_bioUnavailable.value = libraryRepository.refreshArtistBio(id, artistId).isFailure
|
||||
} finally {
|
||||
_bioLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun expandBio() {
|
||||
_bioExpanded.value = true
|
||||
}
|
||||
|
||||
fun collapseBio() {
|
||||
_bioExpanded.value = false
|
||||
}
|
||||
|
||||
suspend fun downloadedBytes(
|
||||
list: DownloadList,
|
||||
refId: String,
|
||||
@@ -84,30 +140,36 @@ class ArtistDetailViewModel(
|
||||
private val _artThumbnail = MutableStateFlow<CoverArtRef?>(null)
|
||||
val artThumbnail: StateFlow<CoverArtRef?> = _artThumbnail
|
||||
|
||||
private val _heroArt = MutableStateFlow<ArtistArt?>(null)
|
||||
val heroArt: StateFlow<ArtistArt?> = _heroArt
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
artist
|
||||
sourceRepository.activeSource
|
||||
.filterNotNull()
|
||||
.collectLatest { loaded ->
|
||||
val (full, thumbnail) = resolveArt(loaded)
|
||||
_art.value = full
|
||||
_artThumbnail.value = thumbnail
|
||||
.flatMapLatest { sourceId ->
|
||||
combine(
|
||||
libraryRepository.artist(sourceId, artistId),
|
||||
libraryRepository.artistCoverArts(sourceId, artistId),
|
||||
sourceRepository.offline,
|
||||
) { loaded, covers, offline -> ArtistArtInput(loaded, artistId, offline, sourceId, covers) }
|
||||
}.collectLatest { input ->
|
||||
val resolved = resolveArtistArtInput(input)
|
||||
if (resolved == null) {
|
||||
_art.value = null
|
||||
_artThumbnail.value = null
|
||||
_heroArt.value = null
|
||||
return@collectLatest
|
||||
}
|
||||
val hero = sourceRepository.artistArt(resolved, forIcon = false)
|
||||
val icon = sourceRepository.artistArt(resolved, forIcon = true)
|
||||
_art.value = hero.image ?: hero.covers.firstOrNull()
|
||||
_artThumbnail.value = icon.image ?: icon.covers.firstOrNull()
|
||||
_heroArt.value = hero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveArt(artist: Artist): Pair<CoverArtRef?, CoverArtRef?> {
|
||||
if (artist.coverArt == null) return null to null
|
||||
repeat(3) { attempt ->
|
||||
val full = sourceRepository.coverArt(artist.coverArt)
|
||||
if (full != null) {
|
||||
return full to sourceRepository.coverArt(artist.coverArt, thumbnail = true)
|
||||
}
|
||||
if (attempt < 2) delay(300)
|
||||
}
|
||||
return null to null
|
||||
}
|
||||
|
||||
fun coverArt(
|
||||
coverArt: String?,
|
||||
thumbnail: Boolean,
|
||||
@@ -119,3 +181,35 @@ class ArtistDetailViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ArtistArtInput(
|
||||
val artist: Artist?,
|
||||
val artistId: String,
|
||||
val offline: Boolean,
|
||||
val sourceId: Long,
|
||||
val covers: List<String>,
|
||||
)
|
||||
|
||||
internal fun resolveArtistArtInput(input: ArtistArtInput): Artist? {
|
||||
val artist = input.artist
|
||||
if (input.covers.isEmpty()) return artist
|
||||
if (artist == null) {
|
||||
return Artist(
|
||||
sourceId = input.sourceId,
|
||||
id = input.artistId,
|
||||
name = "",
|
||||
albumCount = 0,
|
||||
starred = null,
|
||||
coverArts = input.covers,
|
||||
downloadedCoverArts = if (input.offline) input.covers else emptyList(),
|
||||
)
|
||||
}
|
||||
return if (input.offline) {
|
||||
artist.copy(
|
||||
coverArts = input.covers,
|
||||
downloadedCoverArts = artist.downloadedCoverArts.ifEmpty { input.covers },
|
||||
)
|
||||
} else {
|
||||
artist.copy(coverArts = input.covers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ import com.subtracks.R
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ArtistIcon
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.FilteredEmptyState
|
||||
import com.subtracks.ui.components.ListDownloadIndicator
|
||||
@@ -55,6 +56,7 @@ fun ArtistsContent(
|
||||
onSync: () -> Unit = {},
|
||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
scrollToTopKey: Any? = null,
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
@@ -82,7 +84,8 @@ fun ArtistsContent(
|
||||
items(count = items.itemCount, key = items.itemKey { it.id }) { index ->
|
||||
val artist = items[index]
|
||||
if (artist != null) {
|
||||
PrefetchArtworkSeeds(coverArt(artist.coverArt, true))
|
||||
val art = artistArt(artist)
|
||||
PrefetchArtworkSeeds(art.primary)
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
@@ -104,9 +107,9 @@ fun ArtistsContent(
|
||||
)
|
||||
},
|
||||
leadingContent = {
|
||||
CoverArt(
|
||||
ref = coverArt(artist.coverArt, true),
|
||||
name = artist.name,
|
||||
ArtistIcon(
|
||||
artist = artist,
|
||||
art = art,
|
||||
modifier = Modifier.size(48.dp).clip(CircleShape),
|
||||
)
|
||||
},
|
||||
@@ -117,7 +120,7 @@ fun ArtistsContent(
|
||||
Modifier.combinedClickable(
|
||||
onClick = { onArtistClick(artist) },
|
||||
onLongClick = {
|
||||
onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true), downloadStatuses[artist.id]))
|
||||
onLongClick(MenuTarget.Artist(artist, art.primary, downloadStatuses[artist.id]))
|
||||
},
|
||||
),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.subtracks.ui.library
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -24,6 +26,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -42,6 +45,7 @@ import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
@@ -64,13 +68,11 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
@@ -92,12 +94,15 @@ import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.paging.PagingData
|
||||
@@ -118,14 +123,14 @@ import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PendingDownloadDelete
|
||||
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||
import com.subtracks.ui.components.SearchField
|
||||
import com.subtracks.ui.components.bulkRef
|
||||
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||
import com.subtracks.ui.components.statusBarScrim
|
||||
import com.subtracks.ui.home.HomeRoute
|
||||
import com.subtracks.ui.home.HomeSection
|
||||
@@ -136,7 +141,6 @@ import com.subtracks.ui.theme.playerSurfaceColor
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import kotlin.math.abs
|
||||
@@ -271,25 +275,13 @@ fun LibraryRoute(
|
||||
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
|
||||
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
||||
val downloads = downloadsState?.value ?: emptyMap()
|
||||
val scope = rememberCoroutineScope()
|
||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||
val focusManager = LocalFocusManager.current
|
||||
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
|
||||
val itemActions =
|
||||
ItemActions(
|
||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||
bulkDownload = { target, action ->
|
||||
target.bulkRef()?.let { (list, refId) ->
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch {
|
||||
pendingDelete =
|
||||
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
|
||||
}
|
||||
} else {
|
||||
viewModel.onDownloadAction(list, refId, action)
|
||||
}
|
||||
}
|
||||
},
|
||||
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
|
||||
setStar = setStar,
|
||||
viewAlbum = onViewAlbum,
|
||||
viewArtist = onViewArtist,
|
||||
@@ -305,13 +297,17 @@ fun LibraryRoute(
|
||||
artists = viewModel.artists,
|
||||
playlists = viewModel.playlists,
|
||||
coverArt = viewModel::coverArt,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onSync = viewModel::sync,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onItemLongClick = { contextMenuHost?.show(it, itemActions) },
|
||||
onItemLongClick = {
|
||||
focusManager.clearFocus()
|
||||
contextMenuHost?.show(it, itemActions)
|
||||
},
|
||||
listQuery = displayQuery,
|
||||
resetKeys = resetKeys,
|
||||
offline = offline,
|
||||
@@ -365,13 +361,8 @@ fun LibraryRoute(
|
||||
},
|
||||
)
|
||||
|
||||
pendingDelete?.let { pending ->
|
||||
DeleteDownloadsDialog(
|
||||
name = pending.name,
|
||||
bytes = pending.bytes,
|
||||
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
|
||||
onDismiss = { pendingDelete = null },
|
||||
)
|
||||
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
|
||||
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +381,7 @@ fun LibraryScreen(
|
||||
onSync: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
onOpenSearch: () -> Unit = {},
|
||||
offline: Boolean = false,
|
||||
onExitOffline: () -> Unit = {},
|
||||
@@ -424,9 +416,14 @@ fun LibraryScreen(
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
var topRequest by remember { mutableIntStateOf(0) }
|
||||
val searchFocus = remember { FocusRequester() }
|
||||
val closeSearch = {
|
||||
searchActive = false
|
||||
onSearchChange("")
|
||||
}
|
||||
|
||||
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
||||
DismissOnRequest(dismissals) { showOptions = false }
|
||||
BackHandler(enabled = searchActive, onBack = closeSearch)
|
||||
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.settledPage }.collect { page ->
|
||||
@@ -445,14 +442,8 @@ fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissSearch() {
|
||||
if (searchActive) {
|
||||
searchActive = false
|
||||
onSearchChange("")
|
||||
}
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||
val filtersActive = listQuery.starred != StarredFilter.Any || listQuery.downloaded || search.isNotEmpty()
|
||||
var tabBarHeightPx by remember { mutableFloatStateOf(with(density) { (TAB_BAR_CONTENT_HEIGHT + bottomInset).toPx() }) }
|
||||
@@ -503,7 +494,7 @@ fun LibraryScreen(
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
{
|
||||
dismissSearch()
|
||||
focusManager.clearFocus()
|
||||
onAlbumClick(it)
|
||||
},
|
||||
onLongClick = onItemLongClick,
|
||||
@@ -523,7 +514,7 @@ fun LibraryScreen(
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
{
|
||||
dismissSearch()
|
||||
focusManager.clearFocus()
|
||||
onArtistClick(it)
|
||||
},
|
||||
onLongClick = onItemLongClick,
|
||||
@@ -534,6 +525,7 @@ fun LibraryScreen(
|
||||
onSync = onSync,
|
||||
downloadStatuses = artistDownloads,
|
||||
scrollToTopKey = scrollToTopKey,
|
||||
artistArt = artistArt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -543,7 +535,7 @@ fun LibraryScreen(
|
||||
coverArt,
|
||||
listBottomInset,
|
||||
{
|
||||
dismissSearch()
|
||||
focusManager.clearFocus()
|
||||
onPlaylistClick(it)
|
||||
},
|
||||
onLongClick = onItemLongClick,
|
||||
@@ -583,8 +575,6 @@ fun LibraryScreen(
|
||||
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
||||
},
|
||||
onOpenSettings = onOpenSettings,
|
||||
offline = offline,
|
||||
onExitOffline = onExitOffline,
|
||||
artwork = artwork,
|
||||
syncing = syncing,
|
||||
)
|
||||
@@ -595,10 +585,7 @@ fun LibraryScreen(
|
||||
SearchField(
|
||||
value = search,
|
||||
onValueChange = onSearchChange,
|
||||
onClose = {
|
||||
searchActive = false
|
||||
onSearchChange("")
|
||||
},
|
||||
onClose = closeSearch,
|
||||
focusRequester = searchFocus,
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -609,11 +596,29 @@ fun LibraryScreen(
|
||||
}
|
||||
|
||||
if (!searchActive) {
|
||||
val fabContainerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val fabContentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground
|
||||
if (offline) {
|
||||
FilledTonalIconButton(
|
||||
onClick = onExitOffline,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 16.dp, bottom = tabBarHeight + 16.dp)
|
||||
.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CloudOff,
|
||||
contentDescription = stringResource(R.string.offline_mode_status),
|
||||
modifier = Modifier.size(24.dp).offset(y = (-0.75).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
val searchFab = selectedTab == LibraryTab.Home
|
||||
FloatingActionButton(
|
||||
onClick = { if (searchFab) onOpenSearch() else showOptions = true },
|
||||
containerColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.onBackground,
|
||||
containerColor = fabContainerColor,
|
||||
contentColor = fabContentColor,
|
||||
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 12.dp),
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = tabBarHeight + 16.dp),
|
||||
) {
|
||||
@@ -632,11 +637,18 @@ fun LibraryScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 4.dp, y = (-4).dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(artwork?.scheme?.onPrimary ?: MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
.offset(x = 1.dp, y = 2.5.dp)
|
||||
.size(11.dp)
|
||||
.background(fabContainerColor, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(7.dp)
|
||||
.background(fabContentColor, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,7 +708,7 @@ private fun ListOptionsSheet(
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
) {
|
||||
Icon(Icons.Rounded.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(stringResource(R.string.search_this_list), color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -818,6 +830,7 @@ private fun ListOptionsSheet(
|
||||
|
||||
private val BOTTOM_CLEARANCE = 80.dp
|
||||
private val TAB_BAR_CONTENT_HEIGHT = 52.dp
|
||||
private val TAB_STRIP_LEADING_GAP = 8.dp
|
||||
private val TAB_ICON_SIZE = 24.dp
|
||||
private val TAB_VERTICAL_PADDING = 6.dp
|
||||
private val TOUCH_TARGET = 48.dp
|
||||
@@ -850,13 +863,12 @@ internal fun LibraryTabs(
|
||||
pagerState: PagerState,
|
||||
onTabSelected: (LibraryTab) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
offline: Boolean = false,
|
||||
onExitOffline: () -> Unit = {},
|
||||
artwork: ArtworkColors?,
|
||||
syncing: Boolean = false,
|
||||
) {
|
||||
val indicatorStretch = 18.dp
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val bounds = remember { mutableStateMapOf<Int, Rect>() }
|
||||
val page = pagerState.currentPage
|
||||
val fraction = pagerState.currentPageOffsetFraction
|
||||
@@ -888,79 +900,113 @@ internal fun LibraryTabs(
|
||||
val indicatorColor = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.onBackground
|
||||
val tabContentHeightPx = with(density) { (TAB_ICON_SIZE + TAB_VERTICAL_PADDING * 2).toPx() }
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
var viewportWidth by remember { mutableIntStateOf(0) }
|
||||
var contentWidth by remember { mutableIntStateOf(0) }
|
||||
var revealed by remember { mutableStateOf(false) }
|
||||
val leadingGap = with(density) { TAB_STRIP_LEADING_GAP.toPx() }
|
||||
LaunchedEffect(pagerState.settledPage, viewportWidth, contentWidth, layoutDirection, bounds[pagerState.settledPage]) {
|
||||
if (viewportWidth == 0) return@LaunchedEffect
|
||||
val settled = bounds[pagerState.settledPage] ?: return@LaunchedEffect
|
||||
val start = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.right else leadingGap + settled.left
|
||||
val end = if (layoutDirection == LayoutDirection.Rtl) contentWidth - settled.left else leadingGap + settled.right
|
||||
val target =
|
||||
when {
|
||||
start - leadingGap < scrollState.value -> start - leadingGap
|
||||
end > scrollState.value + viewportWidth -> end - viewportWidth
|
||||
else -> null
|
||||
}
|
||||
if (target != null) {
|
||||
val offset = target.roundToInt().coerceIn(0, scrollState.maxValue)
|
||||
if (revealed) scrollState.animateScrollTo(offset) else scrollState.scrollTo(offset)
|
||||
}
|
||||
revealed = true
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(headerColor)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
.padding(top = 2.dp, end = 8.dp, bottom = 2.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.drawBehind {
|
||||
if (current != null && indicatorLeft != null && indicatorRight != null && indicatorRight > indicatorLeft) {
|
||||
val height = tabContentHeightPx.coerceAtMost(current.height)
|
||||
drawRoundRect(
|
||||
color = indicatorColor,
|
||||
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
|
||||
size = Size(indicatorRight - indicatorLeft, height),
|
||||
cornerRadius = CornerRadius(8.dp.toPx()),
|
||||
)
|
||||
}
|
||||
},
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.onSizeChanged { viewportWidth = it.width }
|
||||
.horizontalScroll(scrollState)
|
||||
.onSizeChanged { contentWidth = it.width },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||
TabButton(
|
||||
tab = tab,
|
||||
artwork = artwork,
|
||||
progress =
|
||||
when (index) {
|
||||
page -> 1f - transition
|
||||
page + (if (fraction >= 0f) 1 else -1) -> transition
|
||||
else -> 0f
|
||||
Spacer(Modifier.width(TAB_STRIP_LEADING_GAP))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.drawBehind {
|
||||
if (current != null &&
|
||||
indicatorLeft != null &&
|
||||
indicatorRight != null &&
|
||||
indicatorRight > indicatorLeft
|
||||
) {
|
||||
val height = tabContentHeightPx.coerceAtMost(current.height)
|
||||
drawRoundRect(
|
||||
color = indicatorColor,
|
||||
topLeft = Offset(indicatorLeft, current.top + (current.height - height) / 2f),
|
||||
size = Size(indicatorRight - indicatorLeft, height),
|
||||
cornerRadius = CornerRadius(8.dp.toPx()),
|
||||
)
|
||||
}
|
||||
},
|
||||
indicatorLeft = indicatorLeft,
|
||||
indicatorRight = indicatorRight,
|
||||
onClick = { onTabSelected(tab) },
|
||||
modifier =
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
bounds[index] = coordinates.boundsInParent()
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (offline) {
|
||||
IconButton(onClick = onExitOffline) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CloudOff,
|
||||
contentDescription = stringResource(R.string.offline_mode_status),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
if (syncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
LibraryTab.entries.forEachIndexed { index, tab ->
|
||||
TabButton(
|
||||
tab = tab,
|
||||
artwork = artwork,
|
||||
progress =
|
||||
when (index) {
|
||||
page -> 1f - transition
|
||||
page + (if (fraction >= 0f) 1 else -1) -> transition
|
||||
else -> 0f
|
||||
},
|
||||
indicatorLeft = indicatorLeft,
|
||||
indicatorRight = indicatorRight,
|
||||
onClick = { onTabSelected(tab) },
|
||||
modifier =
|
||||
Modifier.onGloballyPositioned { coordinates ->
|
||||
bounds[index] = coordinates.boundsInParent()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
if (syncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.data.sync.SyncStatus
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -211,6 +213,11 @@ class LibraryViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||
|
||||
fun sync() {
|
||||
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -18,6 +17,7 @@ import androidx.paging.compose.itemKey
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSongItem
|
||||
@@ -27,16 +27,16 @@ import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.HeroDetailScaffold
|
||||
import com.subtracks.ui.components.HeroHeader
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@@ -55,21 +55,17 @@ fun PlaylistDetailRoute(
|
||||
viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) },
|
||||
playbackController: PlaybackController = koinInject(),
|
||||
) {
|
||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle()
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||
val scope = rememberCoroutineScope()
|
||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow()) { _, _ -> viewModel.downloadedBytes() }
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val context = playback.context
|
||||
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
||||
} else {
|
||||
viewModel.onDownloadAction(action)
|
||||
pendingDelete.onAction(playlist?.name.orEmpty(), DownloadList.Playlist, playlistId, action) { _, _, requested ->
|
||||
viewModel.onDownloadAction(requested)
|
||||
}
|
||||
}
|
||||
val actions =
|
||||
@@ -104,13 +100,8 @@ fun PlaylistDetailRoute(
|
||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
|
||||
)
|
||||
|
||||
pendingDelete?.let { bytes ->
|
||||
DeleteDownloadsDialog(
|
||||
name = playlist?.name.orEmpty(),
|
||||
bytes = bytes,
|
||||
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
||||
onDismiss = { pendingDelete = null },
|
||||
)
|
||||
PendingDownloadDeleteConfirmation(pendingDelete) {
|
||||
viewModel.onDownloadAction(BulkDownloadAction.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +129,7 @@ fun PlaylistDetailScreen(
|
||||
artwork = artwork,
|
||||
title = playlist?.name.orEmpty(),
|
||||
onBack = onBack,
|
||||
contentReady = songs.itemCount > 0,
|
||||
header = { controlsModifier, topInset ->
|
||||
HeroHeader(
|
||||
art = coverArt(playlist?.coverArt, false),
|
||||
|
||||
@@ -37,7 +37,10 @@ class PlaylistDetailViewModel(
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val playlist: Flow<Playlist?> = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||
val playlist: StateFlow<Playlist?> =
|
||||
sourceId
|
||||
.flatMapLatest { libraryRepository.playlist(it, playlistId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val songs: Flow<PagingData<PlaylistSongItem>> =
|
||||
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
|
||||
|
||||
@@ -61,6 +61,7 @@ import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.artistSubtitle
|
||||
|
||||
private val TRACK_COLUMN_WIDTH = 24.dp
|
||||
private val TIME_COLUMN_WIDTH = 36.dp
|
||||
@@ -146,7 +147,7 @@ fun SongRow(
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = song.artist.orEmpty().ifEmpty { "\u00A0" },
|
||||
text = song.artistSubtitle().orEmpty().ifEmpty { "\u00A0" },
|
||||
color = if (isPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.subtracks.R
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.playback.PlaybackState
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.artistSubtitle
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.ArtworkTheme
|
||||
import com.subtracks.ui.theme.playerSurfaceColor
|
||||
@@ -139,7 +140,7 @@ fun MiniPlayer(
|
||||
modifier = Modifier.basicMarquee(),
|
||||
)
|
||||
Text(
|
||||
text = item.artist ?: item.album.orEmpty(),
|
||||
text = artistSubtitle(item.artist, item.artists) ?: item.album.orEmpty(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -60,6 +60,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.keepScreenOn
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
@@ -76,6 +77,7 @@ import com.subtracks.R
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.source.StarType
|
||||
@@ -83,10 +85,12 @@ import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.playback.PlaybackState
|
||||
import com.subtracks.playback.QueueItem
|
||||
import com.subtracks.playback.RepeatMode
|
||||
import com.subtracks.ui.components.ArtistChooserSheet
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.artistSubtitle
|
||||
import com.subtracks.ui.components.prefetchArtworkRatio
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||
@@ -191,14 +195,18 @@ fun NowPlayingRoute(
|
||||
controller: PlaybackController = koinInject(),
|
||||
libraryRepository: LibraryRepository = koinInject(),
|
||||
downloadRepository: DownloadRepository = koinInject(),
|
||||
userPreferences: UserPreferences = koinInject(),
|
||||
) {
|
||||
val state by controller.state.collectAsStateWithLifecycle()
|
||||
val keepScreenOn by remember { userPreferences.keepScreenOn() }.collectAsStateWithLifecycle(initialValue = false)
|
||||
val positionMs by controller.positionMs.collectAsStateWithLifecycle()
|
||||
val sourceId = state.context?.sourceId
|
||||
val songId = state.item?.id
|
||||
val downloads by downloadRepository.states().collectAsStateWithLifecycle()
|
||||
val download = songId?.let { downloads[it] }
|
||||
val scope = rememberCoroutineScope()
|
||||
var showArtistChooser by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(songId) { showArtistChooser = false }
|
||||
val song by
|
||||
produceState<Song?>(null, sourceId, songId) {
|
||||
if (sourceId != null && songId != null) {
|
||||
@@ -237,7 +245,7 @@ fun NowPlayingRoute(
|
||||
name = item.title,
|
||||
)
|
||||
var adjacent by remember { mutableStateOf(AdjacentArt()) }
|
||||
LaunchedEffect(state.item?.id, state.layout) {
|
||||
LaunchedEffect(state.item?.id, state.layout, state.repeat) {
|
||||
if (state.item == null) {
|
||||
adjacent = AdjacentArt()
|
||||
return@LaunchedEffect
|
||||
@@ -268,12 +276,32 @@ fun NowPlayingRoute(
|
||||
onRepeat = controller::cycleRepeat,
|
||||
onMore = { song?.let { contextMenuHost?.show(MenuTarget.Song(it, thumbnail, download), actions) } },
|
||||
onAlbumClick = song?.albumId?.let { id -> { onViewAlbum(id) } },
|
||||
onArtistClick = song?.artistId?.let { id -> { onViewArtist(id) } },
|
||||
onArtistClick =
|
||||
song
|
||||
?.takeIf { it.artistRefs.isNotEmpty() }
|
||||
?.let {
|
||||
{
|
||||
val artists = it.artistRefs
|
||||
if (artists.size == 1) onViewArtist(artists.first().id) else showArtistChooser = true
|
||||
}
|
||||
},
|
||||
starred = song?.starred != null,
|
||||
onToggleStar = song?.let { s -> { setStar(StarType.Song, s.id, s.starred == null) } },
|
||||
onSeek = controller::seekTo,
|
||||
keepScreenOn = keepScreenOn,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
if (showArtistChooser) {
|
||||
ArtistChooserSheet(
|
||||
artists = song?.artistRefs.orEmpty(),
|
||||
onArtistClick = { artist ->
|
||||
showArtistChooser = false
|
||||
onViewArtist(artist.id)
|
||||
},
|
||||
onDismiss = { showArtistChooser = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -290,6 +318,7 @@ fun NowPlayingScreen(
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
keepScreenOn: Boolean = false,
|
||||
onSkipPrevious: () -> Unit = onPrevious,
|
||||
onShuffle: () -> Unit = {},
|
||||
onRepeat: () -> Unit = {},
|
||||
@@ -366,7 +395,12 @@ fun NowPlayingScreen(
|
||||
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
|
||||
ArtworkTheme(artwork) {
|
||||
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.then(if (keepScreenOn) Modifier.keepScreenOn() else Modifier),
|
||||
) {
|
||||
HeroGradient(
|
||||
colors = artwork,
|
||||
scrollPx = { 0f },
|
||||
@@ -543,7 +577,10 @@ fun NowPlayingScreen(
|
||||
}
|
||||
Box(Modifier.height(subtitleHeight), contentAlignment = Alignment.CenterStart) {
|
||||
Text(
|
||||
text = state.item?.artist.orEmpty(),
|
||||
text =
|
||||
state.item
|
||||
?.let { artistSubtitle(it.artist, it.artists) }
|
||||
.orEmpty(),
|
||||
style = subtitleStyle,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -45,6 +46,7 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.EmptyState
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
@@ -85,6 +87,7 @@ fun SearchRoute(
|
||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||
val results by viewModel.results.collectAsStateWithLifecycle()
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val focusManager = LocalFocusManager.current
|
||||
var filterMask by rememberSaveable { mutableIntStateOf(allFilterMask()) }
|
||||
val actions =
|
||||
ItemActions(
|
||||
@@ -104,13 +107,29 @@ fun SearchRoute(
|
||||
if (next != 0) filterMask = next
|
||||
},
|
||||
coverArt = viewModel::coverArt,
|
||||
artistArt = { viewModel.artistArt(it) },
|
||||
playingSongId = playback.item?.id,
|
||||
onBack = onBack,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onSongClick = viewModel::play,
|
||||
onLongClick = { contextMenuHost?.show(it, actions) },
|
||||
onAlbumClick = {
|
||||
focusManager.clearFocus()
|
||||
onAlbumClick(it)
|
||||
},
|
||||
onArtistClick = {
|
||||
focusManager.clearFocus()
|
||||
onArtistClick(it)
|
||||
},
|
||||
onPlaylistClick = {
|
||||
focusManager.clearFocus()
|
||||
onPlaylistClick(it)
|
||||
},
|
||||
onSongClick = {
|
||||
focusManager.clearFocus()
|
||||
viewModel.play(it)
|
||||
},
|
||||
onLongClick = {
|
||||
focusManager.clearFocus()
|
||||
contextMenuHost?.show(it, actions)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,6 +148,7 @@ fun SearchScreen(
|
||||
filters: Set<SearchFilter> = ALL_FILTERS,
|
||||
onToggleFilter: (SearchFilter) -> Unit = {},
|
||||
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
|
||||
artistArt: (Artist) -> ArtistArt = { ArtistArt() },
|
||||
playingSongId: String? = null,
|
||||
onBack: () -> Unit = {},
|
||||
onAlbumClick: (Album) -> Unit = {},
|
||||
@@ -198,7 +218,7 @@ fun SearchScreen(
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
) {
|
||||
if (showPlaylists) playlistResults(results.playlists, playlistsLabel, coverArt, onPlaylistClick, onLongClick)
|
||||
if (showArtists) artistResults(results.artists, artistsLabel, coverArt, onArtistClick, onLongClick)
|
||||
if (showArtists) artistResults(results.artists, artistsLabel, artistArt, onArtistClick, onLongClick)
|
||||
if (showAlbums) albumResults(results.albums, albumsLabel, coverArt, onAlbumClick, onLongClick)
|
||||
if (showSongs) songResults(results.songs, songsLabel, coverArt, playingSongId, onSongClick, onLongClick)
|
||||
}
|
||||
@@ -291,19 +311,20 @@ private fun LazyListScope.albumResults(
|
||||
private fun LazyListScope.artistResults(
|
||||
artists: List<Artist>,
|
||||
header: String,
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artistArt: (Artist) -> ArtistArt,
|
||||
onClick: (Artist) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
) {
|
||||
if (artists.isEmpty()) return
|
||||
resultHeader(header)
|
||||
items(artists, key = { "artist-${it.id}" }) { artist ->
|
||||
val art = artistArt(artist)
|
||||
ArtistListRow(
|
||||
artist = artist,
|
||||
coverArt = coverArt,
|
||||
art = art,
|
||||
info = null,
|
||||
onClick = { onClick(artist) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
|
||||
onLongClick = { onLongClick(MenuTarget.Artist(artist, art.primary)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.artistArt
|
||||
import com.subtracks.ui.home.playSongInContext
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -74,6 +76,11 @@ class SearchViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun artistArt(
|
||||
artist: Artist,
|
||||
forIcon: Boolean = true,
|
||||
): ArtistArt = sourceRepository.artistArt(artist, forIcon)
|
||||
|
||||
fun play(song: Song) {
|
||||
viewModelScope.playSongInContext(libraryRepository, playbackController, song)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ internal fun replayGainModeLabel(
|
||||
ReplayGainMode.Off -> resources.getString(R.string.settings_replay_gain_mode_off)
|
||||
ReplayGainMode.Track -> resources.getString(R.string.settings_replay_gain_mode_track)
|
||||
ReplayGainMode.Album -> resources.getString(R.string.settings_replay_gain_mode_album)
|
||||
ReplayGainMode.Auto -> resources.getString(R.string.settings_replay_gain_mode_auto)
|
||||
}
|
||||
|
||||
internal fun replayGainPreampLabel(
|
||||
|
||||
@@ -74,6 +74,7 @@ import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
||||
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.log.Log
|
||||
@@ -101,6 +102,7 @@ private enum class SettingsDialog {
|
||||
SyncMode,
|
||||
ReplayGain,
|
||||
ReplayGainPreamp,
|
||||
SortNames,
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -123,10 +125,12 @@ fun SettingsRoute(
|
||||
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
|
||||
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||
val keepScreenOn by viewModel.keepScreenOn.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
||||
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
|
||||
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
|
||||
val sortNameMode by viewModel.sortNameMode.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
SettingsScreen(
|
||||
@@ -142,6 +146,8 @@ fun SettingsRoute(
|
||||
scrobbling = scrobbling,
|
||||
replayGainMode = replayGainMode,
|
||||
replayGainPreamp = replayGainPreamp,
|
||||
sortNameMode = sortNameMode,
|
||||
keepScreenOn = keepScreenOn,
|
||||
offline = offline,
|
||||
verboseLogging = verboseLogging,
|
||||
onSelectSource = viewModel::selectSource,
|
||||
@@ -156,6 +162,8 @@ fun SettingsRoute(
|
||||
onScrobblingChange = viewModel::setScrobbling,
|
||||
onReplayGainModeChange = viewModel::setReplayGainMode,
|
||||
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
|
||||
onSortNameModeChange = viewModel::setSortNameMode,
|
||||
onKeepScreenOnChange = viewModel::setKeepScreenOn,
|
||||
onOfflineChange = viewModel::setOfflineMode,
|
||||
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||
onAddServer = onAddServer,
|
||||
@@ -195,12 +203,14 @@ fun SettingsScreen(
|
||||
downloadOverMetered: Boolean,
|
||||
scrobbling: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
keepScreenOn: Boolean = false,
|
||||
offline: Boolean = false,
|
||||
verboseLogging: Boolean = false,
|
||||
syncMode: SyncMode = SyncMode.WifiOnly,
|
||||
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||
replayGainMode: ReplayGainMode = ReplayGainMode.Track,
|
||||
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
|
||||
replayGainPreamp: Float = 0f,
|
||||
sortNameMode: SortNameMode = SortNameMode.Name,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onEditServer: (Long) -> Unit,
|
||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||
@@ -213,6 +223,8 @@ fun SettingsScreen(
|
||||
onScrobblingChange: (Boolean) -> Unit,
|
||||
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
|
||||
onReplayGainPreampChange: (Float) -> Unit = {},
|
||||
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
||||
onKeepScreenOnChange: (Boolean) -> Unit = {},
|
||||
onOfflineChange: (Boolean) -> Unit = {},
|
||||
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||
onAddServer: () -> Unit,
|
||||
@@ -319,14 +331,6 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
|
||||
supportingContent = { Text(concurrencyLabel(LocalResources.current, syncConcurrency)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
|
||||
@@ -343,6 +347,23 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
|
||||
supportingContent = { Text(concurrencyLabel(LocalResources.current, syncConcurrency)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.settings_library_name)) }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_sort_names_title)) },
|
||||
supportingContent = { Text(sortNameModeLabel(LocalResources.current, sortNameMode)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.SortNames },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
||||
item {
|
||||
ListItem(
|
||||
@@ -380,6 +401,26 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_music_options_keep_screen_on_title)) },
|
||||
supportingContent = { Text(stringResource(R.string.settings_music_options_keep_screen_on_description)) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = keepScreenOn,
|
||||
onCheckedChange = onKeepScreenOnChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onKeepScreenOnChange(!keepScreenOn) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.downloads_title)) }
|
||||
item {
|
||||
ListItem(
|
||||
@@ -578,6 +619,14 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SettingsDialog.SortNames -> {
|
||||
SortNameModeDialog(
|
||||
selected = sortNameMode,
|
||||
onSelect = onSortNameModeChange,
|
||||
onDismiss = { dialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
@@ -731,6 +780,52 @@ private fun SyncModeDialog(
|
||||
)
|
||||
}
|
||||
|
||||
private val sortNameModeOptions =
|
||||
listOf(SortNameMode.Name, SortNameMode.SortName, SortNameMode.NonLatin)
|
||||
|
||||
private fun sortNameModeLabel(
|
||||
resources: Resources,
|
||||
mode: SortNameMode,
|
||||
): String =
|
||||
when (mode) {
|
||||
SortNameMode.SortName -> resources.getString(R.string.sort_name_mode_sort_name)
|
||||
SortNameMode.Name -> resources.getString(R.string.sort_name_mode_name)
|
||||
SortNameMode.NonLatin -> resources.getString(R.string.sort_name_mode_non_latin)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SortNameModeDialog(
|
||||
selected: SortNameMode,
|
||||
onSelect: (SortNameMode) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
var draft by remember(selected) { mutableStateOf(selected) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_sort_names_title)) },
|
||||
text = {
|
||||
ChoiceGroup(
|
||||
header = stringResource(R.string.settings_sort_names_prompt),
|
||||
options = sortNameModeOptions.map { it to sortNameModeLabel(resources, it) },
|
||||
selected = draft,
|
||||
onSelect = { draft = it },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSelect(draft)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.action_done)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplayGainModeDialog(
|
||||
selected: ReplayGainMode,
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.ReplayGainMode
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
@@ -69,13 +70,23 @@ class SettingsViewModel(
|
||||
val replayGainMode: StateFlow<ReplayGainMode> =
|
||||
userPreferences
|
||||
.replayGainMode()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Track)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ReplayGainMode.Auto)
|
||||
|
||||
val replayGainPreamp: StateFlow<Float> =
|
||||
userPreferences
|
||||
.replayGainPreampDb()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0f)
|
||||
|
||||
val sortNameMode: StateFlow<SortNameMode> =
|
||||
userPreferences
|
||||
.sortNameMode()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SortNameMode.Name)
|
||||
|
||||
val keepScreenOn: StateFlow<Boolean> =
|
||||
userPreferences
|
||||
.keepScreenOn()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||
|
||||
val verboseLogging: StateFlow<Boolean> =
|
||||
@@ -134,4 +145,12 @@ class SettingsViewModel(
|
||||
fun setReplayGainPreamp(value: Float) {
|
||||
viewModelScope.launch { userPreferences.setReplayGainPreampDb(value) }
|
||||
}
|
||||
|
||||
fun setKeepScreenOn(enabled: Boolean) {
|
||||
viewModelScope.launch { userPreferences.setKeepScreenOn(enabled) }
|
||||
}
|
||||
|
||||
fun setSortNameMode(mode: SortNameMode) {
|
||||
viewModelScope.launch { userPreferences.setSortNameMode(mode) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@
|
||||
<string name="resources_filter_artist">Artist</string>
|
||||
<string name="artist_type_artist">Artist</string>
|
||||
<string name="artist_type_album_artist">Album artist</string>
|
||||
<string name="artist_section_albums">Albums</string>
|
||||
<string name="artist_section_appearances">Appears on</string>
|
||||
<string name="artist_bio_empty">No artist bio available.</string>
|
||||
<string name="artist_bio_loading">Loading artist bio…</string>
|
||||
<string name="artist_bio_unavailable">Artist bio unavailable.</string>
|
||||
<string name="artist_bio_more">More</string>
|
||||
<string name="artist_bio_less">Less</string>
|
||||
<string name="artist_meta">%1$s · %2$s</string>
|
||||
<string name="resources_filter_genre">Genre</string>
|
||||
<string name="resources_filter_starred">Starred</string>
|
||||
<string name="resources_song_list_delete_all_content">%1$s\n\nDeleting will free %2$s</string>
|
||||
@@ -71,20 +79,29 @@
|
||||
<string name="settings_about_verbose_logging">Verbose logging</string>
|
||||
<string name="settings_about_verbose_logging_description">Log extra detail for troubleshooting</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_library_name">Library</string>
|
||||
<string name="settings_music_name">Playback</string>
|
||||
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
||||
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
|
||||
<string name="settings_replay_gain_mode_album">Album gain</string>
|
||||
<string name="settings_replay_gain_mode_auto">Auto</string>
|
||||
<string name="settings_replay_gain_mode_off">Off</string>
|
||||
<string name="settings_replay_gain_mode_track">Track gain</string>
|
||||
<string name="settings_replay_gain_preamp_decrease">Lower preamp</string>
|
||||
<string name="settings_replay_gain_preamp_increase">Raise preamp</string>
|
||||
<string name="settings_replay_gain_preamp_prompt">Extra level on top of ReplayGain</string>
|
||||
<string name="settings_replay_gain_preamp_prompt">Extra level on top of ReplayGain (tracks without peak data cannot be boosted)</string>
|
||||
<string name="settings_replay_gain_preamp_reset">Reset</string>
|
||||
<string name="settings_replay_gain_preamp_title">Preamp</string>
|
||||
<string name="settings_replay_gain_preamp_value">%1$s dB</string>
|
||||
<string name="settings_replay_gain_prompt">Normalise loudness using the ReplayGain tags from the server</string>
|
||||
<string name="settings_replay_gain_title">ReplayGain</string>
|
||||
<string name="settings_music_options_keep_screen_on_description">Keep the screen on while now playing is open</string>
|
||||
<string name="settings_music_options_keep_screen_on_title">Keep screen on</string>
|
||||
<string name="settings_sort_names_title">Sort names</string>
|
||||
<string name="settings_sort_names_prompt">Choose how names are ordered in album and artist lists.</string>
|
||||
<string name="sort_name_mode_sort_name">Use sort names</string>
|
||||
<string name="sort_name_mode_name">Use regular names</string>
|
||||
<string name="sort_name_mode_non_latin">Non-Latin names only</string>
|
||||
<string name="settings_network_name">Network</string>
|
||||
<string name="settings_network_options_max_bitrate_mobile_title">Stream quality: Mobile</string>
|
||||
<string name="settings_network_options_max_bitrate_wifi_title">Stream quality: Wi-Fi</string>
|
||||
@@ -138,6 +155,7 @@
|
||||
<string name="more_options">More options</string>
|
||||
<string name="open_album">Open album</string>
|
||||
<string name="open_artist">Open artist</string>
|
||||
<string name="artist_chooser_title">Artists</string>
|
||||
<string name="unknown_artist">Unknown artist</string>
|
||||
<string name="unknown_album">Unknown album</string>
|
||||
|
||||
@@ -179,6 +197,24 @@
|
||||
<string name="info_sample_rate_value">%1$d Hz</string>
|
||||
<string name="info_channels">Channels</string>
|
||||
<string name="info_close">Close</string>
|
||||
<string name="info_artists">Artists</string>
|
||||
<string name="info_artists_sort">Artists sort</string>
|
||||
<string name="info_album_artists">Album artists</string>
|
||||
<string name="info_album_artist_sort">Album artist sort</string>
|
||||
<string name="info_sort_title">Sort title</string>
|
||||
<string name="info_artist_sort">Artist sort</string>
|
||||
<string name="info_sort_name">Sort name</string>
|
||||
<string name="info_albums">Albums</string>
|
||||
<string name="info_songs">Songs</string>
|
||||
<string name="info_plays">Plays</string>
|
||||
<string name="info_last_played">Last played</string>
|
||||
<string name="info_added">Added</string>
|
||||
<string name="info_created">Created</string>
|
||||
<string name="info_updated">Updated</string>
|
||||
<string name="info_owner">Owner</string>
|
||||
<string name="info_shared">Shared</string>
|
||||
<string name="info_length">Length</string>
|
||||
<string name="info_downloaded_count">%1$d of %2$d</string>
|
||||
|
||||
<string name="disc_header">Disc %1$d</string>
|
||||
<string name="disc_track">%1$s: Disc %2$d</string>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AlbumCreditsTest {
|
||||
@Test
|
||||
fun everyVariantUsesAllFourCreditPaths() {
|
||||
val variants =
|
||||
listOf(
|
||||
CREDITED_ALBUM_ROWS,
|
||||
DOWNLOADED_ALBUM_ROWS,
|
||||
DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST,
|
||||
DOWNLOADED_COVER_ALBUM_ROWS,
|
||||
ARTIST_COVER_CREDITS,
|
||||
ARTIST_COVER_CREDITS_FOR_ARTIST,
|
||||
ALBUMS_FOR_ARTIST_DOWNLOADED,
|
||||
)
|
||||
variants.forEach { sql ->
|
||||
assertEquals(sql, 1, sql.occurrences("FROM albums"))
|
||||
assertEquals(sql, 1, sql.occurrences("FROM album_artists"))
|
||||
assertEquals(sql, 1, sql.occurrences("FROM songs JOIN albums"))
|
||||
assertEquals(sql, 1, sql.occurrences("FROM song_artists"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun variantsKeepTheirOwnFiltersAndDuplicateSemantics() {
|
||||
assertFalse(CREDITED_ALBUM_ROWS.contains("coverArt IS NOT NULL"))
|
||||
assertFalse(CREDITED_ALBUM_ROWS.contains("song_downloads"))
|
||||
assertTrue(CREDITED_ALBUM_ROWS.contains(" UNION ALL "))
|
||||
assertEquals(4, ARTIST_COVER_CREDITS.occurrences("coverArt IS NOT NULL"))
|
||||
assertEquals(4, ARTIST_COVER_CREDITS_FOR_ARTIST.occurrences("= :artistId"))
|
||||
assertEquals(4, DOWNLOADED_ALBUM_ROWS.occurrences("sd.status = 'Completed'"))
|
||||
assertEquals(4, DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST.occurrences("= :artistId"))
|
||||
assertEquals(4, DOWNLOADED_COVER_ALBUM_ROWS.occurrences("coverArt IS NOT NULL"))
|
||||
assertTrue(DOWNLOADED_ALBUM_ROWS.contains(" UNION "))
|
||||
assertFalse(DOWNLOADED_ALBUM_ROWS.contains(" UNION ALL "))
|
||||
assertFalse(DOWNLOADED_ALBUM_ROWS.contains("creditType"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun consumersEmbedTheSharedVariants() {
|
||||
assertTrue(ALBUMS_FOR_ARTIST.contains(CREDITED_ALBUM_ROWS))
|
||||
assertTrue(ALBUM_CREDIT_ALBUM_IDS.contains(CREDITED_ALBUM_ROWS))
|
||||
assertTrue(ARTIST_AGGREGATES_SQL.contains(CREDITED_ALBUM_ROWS))
|
||||
assertTrue(DOWNLOADED_ARTIST_ALBUMS.contains(DOWNLOADED_ALBUM_ROWS))
|
||||
assertTrue(ALBUMS_FOR_ARTIST_DOWNLOADED.contains(DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST))
|
||||
assertTrue(ARTIST_COVER_ARTS_SQL.contains(ARTIST_COVER_CREDITS_FOR_ARTIST))
|
||||
assertTrue(ARTIST_ART_UPDATE_SQL.contains(ARTIST_COVER_CREDITS))
|
||||
assertTrue(ARTIST_ART_UPDATE_SQL.contains(DOWNLOADED_COVER_ALBUM_ROWS))
|
||||
assertTrue(DOWNLOADED_ARTIST_ART_UPDATE_SQL.contains(DOWNLOADED_COVER_ALBUM_ROWS))
|
||||
}
|
||||
|
||||
private fun String.occurrences(value: String): Int = split(value).size - 1
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.ArtworkSeed
|
||||
import com.subtracks.data.model.Source
|
||||
@@ -22,12 +18,7 @@ class ArtworkSeedDaoTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
}
|
||||
|
||||
@After
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,7 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
@@ -27,12 +23,7 @@ class LibraryHomeDaoTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
}
|
||||
|
||||
@After
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
|
||||
internal fun LibraryDao.albumPages(
|
||||
sourceId: Long,
|
||||
starredFilter: Int = 0,
|
||||
search: String = "",
|
||||
sort: AlbumSort = AlbumSort.Name,
|
||||
descending: Boolean = false,
|
||||
downloaded: Boolean = false,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): PagingSource<Int, Album> =
|
||||
if (downloaded) {
|
||||
downloadedAlbumsPaging(
|
||||
albumsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starredFilter,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = true,
|
||||
sortNameMode = sortNameMode,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
albumsPaging(
|
||||
albumsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starredFilter,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = false,
|
||||
sortNameMode = sortNameMode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun LibraryDao.artistPages(
|
||||
sourceId: Long,
|
||||
starredFilter: Int = 0,
|
||||
search: String = "",
|
||||
sort: ArtistSort = ArtistSort.Name,
|
||||
descending: Boolean = false,
|
||||
downloaded: Boolean = false,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): PagingSource<Int, Artist> =
|
||||
if (downloaded) {
|
||||
downloadedArtistsPaging(
|
||||
artistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starredFilter,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = true,
|
||||
sortNameMode = sortNameMode,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
artistsPaging(
|
||||
artistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starredFilter,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = false,
|
||||
sortNameMode = sortNameMode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun LibraryDao.playlistPages(
|
||||
sourceId: Long,
|
||||
search: String = "",
|
||||
sort: PlaylistSort = PlaylistSort.Name,
|
||||
descending: Boolean = false,
|
||||
downloaded: Boolean = false,
|
||||
): PagingSource<Int, Playlist> =
|
||||
if (downloaded) {
|
||||
downloadedPlaylistsPaging(
|
||||
playlistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = true,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
playlistsPaging(
|
||||
playlistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
@@ -54,17 +55,19 @@ class MigrationsTest {
|
||||
applySchema(migrated, versions.first())
|
||||
MIGRATIONS.sortedBy { it.startVersion }.forEach { it.migrate(migrated) }
|
||||
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "name").collation)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtist").collation)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "name").collation)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "sortName").collation)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtistSort").collation)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "sortName").collation)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_year", "year").descending)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "name").collation)
|
||||
assertEquals(0, indexColumn(migrated, "index_albums_year", "name").descending)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "sortName").collation)
|
||||
assertEquals(0, indexColumn(migrated, "index_albums_year", "sortName").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_added", "created").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_starred", "starred").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_recent", "played").descending)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "name").collation)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_songs_starred", "sortName").collation)
|
||||
assertEquals(1, indexColumn(migrated, "index_songs_starred", "starred").descending)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "sortName").collation)
|
||||
assertEquals(1, indexColumn(migrated, "index_artists_albumCount", "albumCount").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_artists_frequent", "playCount").descending)
|
||||
@@ -134,6 +137,128 @@ class MigrationsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theArtistBioTableStoresTheLatestBiography() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 34)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
|
||||
MIGRATION_34_35.migrate(migrated)
|
||||
migrated.execSQL("INSERT INTO artist_bios (sourceId, artistId, biography) VALUES (1, 'ar1', 'Old')")
|
||||
migrated.execSQL("UPDATE artist_bios SET biography = 'New' WHERE sourceId = 1 AND artistId = 'ar1'")
|
||||
|
||||
assertEquals("New", text(migrated, "SELECT biography FROM artist_bios WHERE artistId = 'ar1'"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theArtistArtColumnsDefaultToEmptyAndAcceptValues() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 35)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
|
||||
"VALUES (1, 'ar1', 'Artist', 0, NULL)",
|
||||
)
|
||||
|
||||
MIGRATION_35_36.migrate(migrated)
|
||||
|
||||
assertEquals(1L, long(migrated, "SELECT artistImageUrl IS NULL FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
|
||||
migrated.execSQL(
|
||||
"UPDATE artists SET artistImageUrl = 'http://img', coverArts = 'a\u001fb', " +
|
||||
"downloadedCoverArts = 'c' WHERE id = 'ar1'",
|
||||
)
|
||||
assertEquals("http://img", text(migrated, "SELECT artistImageUrl FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("a\u001fb", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("c", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theArtistArtMigrationBackfillsExistingLibraries() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 35)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
|
||||
"VALUES (1, 'ar1', 'Artist', 3, NULL)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, " +
|
||||
"year, starred, songCount) VALUES " +
|
||||
"(1, 'al1', 'ar1', 'Shared', 'Artist', 0, 'shared', NULL, 2020, NULL, 1), " +
|
||||
"(1, 'al2', 'ar1', 'Shared Newer', 'Artist', 0, 'shared', NULL, 2021, NULL, 1), " +
|
||||
"(1, 'al3', 'ar1', 'Other', 'Artist', 0, 'other', NULL, 2019, NULL, 1)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES " +
|
||||
"(1, 's1', 'al1', 'ar1', 'One'), (1, 's2', 'al2', 'ar1', 'Two'), " +
|
||||
"(1, 's3', 'al3', 'ar1', 'Three')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO song_downloads (sourceId, songId, status, bytes, total) VALUES " +
|
||||
"(1, 's1', 'Completed', 1, 1), (1, 's2', 'Completed', 1, 1)",
|
||||
)
|
||||
|
||||
MIGRATION_35_36.migrate(migrated)
|
||||
|
||||
assertEquals("shared\u001fother", text(migrated, "SELECT coverArts FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("shared", text(migrated, "SELECT downloadedCoverArts FROM artists WHERE id = 'ar1'"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theArtistSearchTriggersIgnoreArtOnlyUpdates() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 35)
|
||||
MIGRATION_35_36.migrate(migrated)
|
||||
|
||||
assertTrue(artistSearchTriggersOptimized(migrated))
|
||||
assertTrue(
|
||||
triggerSql(migrated, "room_fts_content_sync_artist_search_BEFORE_UPDATE")
|
||||
.contains("UPDATE OF `name`, `sortName`"),
|
||||
)
|
||||
assertTrue(
|
||||
triggerSql(migrated, "room_fts_content_sync_artist_search_AFTER_UPDATE")
|
||||
.contains("UPDATE OF `name`, `sortName`"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theFreshDatabaseCallbackOptimizesTheArtistSearchTriggers() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { connection ->
|
||||
applySchema(connection, 36)
|
||||
connection.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
|
||||
"VALUES (1, 'ar1', 'Artist', 0, NULL)",
|
||||
)
|
||||
|
||||
ArtistSearchTriggerOptimization.onOpen(connection)
|
||||
assertTrue(artistSearchTriggersOptimized(connection))
|
||||
val before = long(connection, "SELECT total_changes()")
|
||||
connection.execSQL("UPDATE artists SET coverArts = 'a' WHERE id = 'ar1'")
|
||||
|
||||
assertEquals(before + 1, long(connection, "SELECT total_changes()"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theGenreBackfillTrimsAndSkipsBlankSingularGenres() =
|
||||
runTest {
|
||||
@@ -153,6 +278,211 @@ class MigrationsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theArtistSongCountBackfillCountsLegacyCredits() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 29)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'ar1', 'Album Artist', 1)")
|
||||
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'ar2', 'Guest', 0)")
|
||||
migrated.execSQL(
|
||||
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
|
||||
"VALUES (1, 'al1', 'ar1', 'Album', 'Album Artist', 0, 1)",
|
||||
)
|
||||
migrated.execSQL("INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES (1, 's1', 'al1', 'ar1', 'One')")
|
||||
migrated.execSQL("INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES (1, 's2', NULL, 'ar2', 'Two')")
|
||||
|
||||
MIGRATION_29_30.migrate(migrated)
|
||||
|
||||
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'ar2'"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSortNameBackfillUsesDisplayNamesAndArtistSortNames() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 30)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount) " +
|
||||
"VALUES (1, 'ar1', 'Utada Hikaru', 1)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
|
||||
"VALUES (1, 'al1', 'ar1', 'First Love', 'Utada Hikaru', 0, 1)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO songs (sourceId, id, albumId, artistId, title) " +
|
||||
"VALUES (1, 's1', 'al1', 'ar1', 'Automatic')",
|
||||
)
|
||||
|
||||
MIGRATION_30_31.migrate(migrated)
|
||||
|
||||
assertEquals("Utada Hikaru", text(migrated, "SELECT sortName FROM artists WHERE id = 'ar1'"))
|
||||
assertEquals("First Love", text(migrated, "SELECT sortName FROM albums WHERE id = 'al1'"))
|
||||
assertEquals("Utada Hikaru", text(migrated, "SELECT albumArtistSort FROM albums WHERE id = 'al1'"))
|
||||
assertEquals("Automatic", text(migrated, "SELECT sortName FROM songs WHERE id = 's1'"))
|
||||
assertEquals("Utada Hikaru", text(migrated, "SELECT artistSort FROM songs WHERE id = 's1'"))
|
||||
assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM artist_search WHERE artist_search MATCH 'utada'"))
|
||||
assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM album_search WHERE album_search MATCH 'first'"))
|
||||
assertEquals(1L, long(migrated, "SELECT COUNT(*) FROM song_search WHERE song_search MATCH 'utada'"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theArtistCreditedFlagsHideRowsWithoutCredits() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 31)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'a1', 'Alpha', 1)")
|
||||
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'combo', 'Alpha & Beta', 1)")
|
||||
migrated.execSQL(
|
||||
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
|
||||
"VALUES (1, 'al1', 'a1', 'Album', 'Alpha', 0, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
|
||||
"VALUES (1, 'al1', 0, 'a1', 'Alpha')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO songs (sourceId, id, albumId, artistId, title) VALUES (1, 's1', 'al1', 'a1', 'Song')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||
"VALUES (1, 's1', 0, 'a1', 'Alpha')",
|
||||
)
|
||||
|
||||
MIGRATION_31_32.migrate(migrated)
|
||||
|
||||
assertEquals(1L, long(migrated, "SELECT credited FROM artists WHERE id = 'a1'"))
|
||||
assertEquals(0L, long(migrated, "SELECT credited FROM artists WHERE id = 'combo'"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theCreditedBackfillKeepsArtistsVisibleWhileRelationsAreIncomplete() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 31)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'a1', 'Alpha', 1)")
|
||||
migrated.execSQL("INSERT INTO artists (sourceId, id, name, albumCount) VALUES (1, 'combo', 'Alpha & Beta', 1)")
|
||||
migrated.execSQL(
|
||||
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
|
||||
"VALUES (1, 'al1', 'a1', 'Album', 'Alpha', 0, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
|
||||
"VALUES (1, 'al1', 0, 'a1', 'Alpha')",
|
||||
)
|
||||
|
||||
MIGRATION_31_32.migrate(migrated)
|
||||
|
||||
assertEquals(1L, long(migrated, "SELECT credited FROM artists WHERE id = 'a1'"))
|
||||
assertEquals(1L, long(migrated, "SELECT credited FROM artists WHERE id = 'combo'"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theCreditTotalsBackfillUsesEveryCreditedSong() =
|
||||
runTest {
|
||||
driver.open(":memory:").use { migrated ->
|
||||
applySchema(migrated, 33)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 's', 'http://x', 1, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (2, 't', 'http://y', 1, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (3, 'u', 'http://z', 1, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
|
||||
"VALUES (1, 'a1', 'Album Artist', 1, 0, 0, NULL)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
|
||||
"VALUES (1, 'a2', 'Guest', 0, 0, 0, NULL)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
|
||||
"VALUES (2, 'b1', 'Unsynced', 7, 6, 5, 100)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO artists (sourceId, id, name, albumCount, songCount, playCount, played) " +
|
||||
"VALUES (3, 'c1', 'Track Only', 7, 6, 5, 100)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
|
||||
"VALUES (1, 'al1', 'a1', 'Album', 'Album Artist', 0, 1)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, songCount) " +
|
||||
"VALUES (1, 'al2', 'a2', 'Empty Album', 'Guest', 0, 0)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO album_artists (sourceId, albumId, position, artistId, name) " +
|
||||
"VALUES (1, 'al1', 0, 'a1', 'Album Artist')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO songs (sourceId, id, albumId, artistId, title, playCount, played) " +
|
||||
"VALUES (1, 's1', 'al1', 'a1', 'One', 4, 200)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||
"VALUES (1, 's1', 0, 'a1', 'Album Artist')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||
"VALUES (1, 's1', 1, 'a2', 'Guest')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO songs (sourceId, id, albumId, artistId, title, playCount, played) " +
|
||||
"VALUES (3, 's3', NULL, 'c1', 'Three', 2, 50)",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO song_artists (sourceId, songId, position, artistId, name) " +
|
||||
"VALUES (3, 's3', 0, 'c1', 'Track Only')",
|
||||
)
|
||||
migrated.execSQL(
|
||||
"INSERT INTO songs (sourceId, id, albumId, artistId, title, playCount, played) " +
|
||||
"VALUES (3, 's4', 'missing', 'c1', 'Four', 3, 60)",
|
||||
)
|
||||
|
||||
MIGRATION_33_34.migrate(migrated)
|
||||
|
||||
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'a1'"))
|
||||
assertEquals(1L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'a1'"))
|
||||
assertEquals(4L, long(migrated, "SELECT playCount FROM artists WHERE id = 'a1'"))
|
||||
assertEquals(200L, long(migrated, "SELECT played FROM artists WHERE id = 'a1'"))
|
||||
assertEquals(1L, long(migrated, "SELECT songCount FROM artists WHERE id = 'a2'"))
|
||||
assertEquals(2L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'a2'"))
|
||||
assertEquals(4L, long(migrated, "SELECT playCount FROM artists WHERE id = 'a2'"))
|
||||
assertEquals(200L, long(migrated, "SELECT played FROM artists WHERE id = 'a2'"))
|
||||
assertEquals(6L, long(migrated, "SELECT songCount FROM artists WHERE id = 'b1'"))
|
||||
assertEquals(7L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'b1'"))
|
||||
assertEquals(5L, long(migrated, "SELECT playCount FROM artists WHERE id = 'b1'"))
|
||||
assertEquals(100L, long(migrated, "SELECT played FROM artists WHERE id = 'b1'"))
|
||||
assertEquals(2L, long(migrated, "SELECT songCount FROM artists WHERE id = 'c1'"))
|
||||
assertEquals(0L, long(migrated, "SELECT albumCount FROM artists WHERE id = 'c1'"))
|
||||
assertEquals(5L, long(migrated, "SELECT playCount FROM artists WHERE id = 'c1'"))
|
||||
assertEquals(60L, long(migrated, "SELECT played FROM artists WHERE id = 'c1'"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun text(
|
||||
connection: SQLiteConnection,
|
||||
sql: String,
|
||||
@@ -171,6 +501,16 @@ class MigrationsTest {
|
||||
statement.getLong(0)
|
||||
}
|
||||
|
||||
private fun triggerSql(
|
||||
connection: SQLiteConnection,
|
||||
trigger: String,
|
||||
): String =
|
||||
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?").use { statement ->
|
||||
statement.bindText(1, trigger)
|
||||
check(statement.step()) { "No trigger $trigger" }
|
||||
statement.getText(0)
|
||||
}
|
||||
|
||||
private fun indexColumn(
|
||||
connection: SQLiteConnection,
|
||||
index: String,
|
||||
@@ -204,6 +544,10 @@ class MigrationsTest {
|
||||
for (i in 0 until indices.length()) {
|
||||
connection.execSQL(indices.getJSONObject(i).getString("createSql").replace("\${TABLE_NAME}", name))
|
||||
}
|
||||
val triggers = entity.optJSONArray("contentSyncTriggers") ?: continue
|
||||
for (i in 0 until triggers.length()) {
|
||||
connection.execSQL(triggers.getString(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +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
|
||||
@@ -12,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
|
||||
@@ -35,9 +37,8 @@ class QueryTimingMeasurementTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
|
||||
db = Room.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java).setDriver(driver).build()
|
||||
db = createTestDatabase(driver)
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -61,6 +62,27 @@ class QueryTimingMeasurementTest {
|
||||
val secondSyncMs = wall { SyncService(db, source).sync() }
|
||||
val secondSyncExecutions = executions.toList()
|
||||
|
||||
db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
connection.usePrepared(
|
||||
"INSERT INTO song_downloads (sourceId, songId, status, downloadedAt) VALUES (?, ?, ?, ?)",
|
||||
) { statement ->
|
||||
(0 until songCount step 10).forEach { i ->
|
||||
statement.bindLong(1, 1L)
|
||||
statement.bindText(2, "s$i")
|
||||
statement.bindText(3, "Completed")
|
||||
statement.bindLong(4, i.toLong())
|
||||
statement.step()
|
||||
statement.reset()
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
db.useWriterConnection { connection ->
|
||||
connection.usePrepared("ANALYZE") { it.step() }
|
||||
}
|
||||
|
||||
executions.clear()
|
||||
measureReads()
|
||||
val readExecutions = executions.toList()
|
||||
@@ -82,19 +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("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("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.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.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() }
|
||||
@@ -102,7 +142,7 @@ class QueryTimingMeasurementTest {
|
||||
timed("home recently starred") { dao.recentlyStarredSongs(1, 20).first() }
|
||||
timed("home genres") { dao.genresByMostPlayed(1).first() }
|
||||
timed("home decades") { dao.decades(1).first() }
|
||||
timed("recompute play data") { dao.recomputePlayData(1) }
|
||||
timed("recompute aggregates") { dao.recomputeAggregates(1) }
|
||||
}
|
||||
|
||||
private suspend fun <T : Any> PagingSource<Int, T>.firstPage(): List<T> =
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.SQLiteDriver
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
|
||||
fun createTestDatabase(driver: SQLiteDriver = BundledSQLiteDriver()): SubtracksDatabase {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
return Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(driver)
|
||||
.addSubtracksDatabaseCallbacks()
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.subtracks.data.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ArtistRefsCodecTest {
|
||||
@Test
|
||||
fun roundTripsNamesWithTheOldSeparatorsAndColons() {
|
||||
val refs =
|
||||
listOf(
|
||||
ArtistRef("ar1", "Name\u001Ewith\u001Fseparators"),
|
||||
ArtistRef("ar:2", "1:Colon Artist"),
|
||||
ArtistRef("", ""),
|
||||
)
|
||||
|
||||
assertEquals(refs, ArtistRefsCodec.decode(ArtistRefsCodec.encode(refs)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesEmptyAndMalformedValuesAsEmpty() {
|
||||
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode(""))
|
||||
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode("not a ref"))
|
||||
assertEquals(emptyList<ArtistRef>(), ArtistRefsCodec.decode("9:short4:name"))
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,22 @@ class UserPreferencesTest {
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSortNameModeRoundTrips() =
|
||||
runTest {
|
||||
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val prefs = UserPreferences(store)
|
||||
|
||||
assertEquals(SortNameMode.Name, prefs.sortNameMode().first())
|
||||
|
||||
prefs.setSortNameMode(SortNameMode.NonLatin)
|
||||
|
||||
assertEquals(SortNameMode.NonLatin, prefs.sortNameMode().first())
|
||||
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSelectedLibraryTabRoundTrips() =
|
||||
runTest {
|
||||
@@ -155,6 +171,24 @@ class UserPreferencesTest {
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepScreenOnDefaultsOffAndRoundTrips() =
|
||||
runTest {
|
||||
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val prefs = UserPreferences(store)
|
||||
|
||||
assertEquals(false, prefs.keepScreenOn().first())
|
||||
|
||||
prefs.setKeepScreenOn(true)
|
||||
assertEquals(true, prefs.keepScreenOn().first())
|
||||
|
||||
prefs.setKeepScreenOn(false)
|
||||
assertEquals(false, prefs.keepScreenOn().first())
|
||||
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lastSeedRoundTripsWithAndWithoutASecondary() =
|
||||
runTest {
|
||||
@@ -192,13 +226,13 @@ class UserPreferencesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replayGainDefaultsToTrackModeAndRoundTrips() =
|
||||
fun replayGainDefaultsToAutoModeAndRoundTrips() =
|
||||
runTest {
|
||||
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val prefs = UserPreferences(store)
|
||||
|
||||
assertEquals(ReplayGainMode.Track, prefs.replayGainMode().first())
|
||||
assertEquals(ReplayGainMode.Auto, prefs.replayGainMode().first())
|
||||
assertEquals(0f, prefs.replayGainPreampDb().first(), 0f)
|
||||
|
||||
prefs.setReplayGainMode(ReplayGainMode.Album)
|
||||
@@ -207,6 +241,9 @@ class UserPreferencesTest {
|
||||
assertEquals(ReplayGainMode.Album, prefs.replayGainMode().first())
|
||||
assertEquals(3.5f, prefs.replayGainPreampDb().first(), 0f)
|
||||
|
||||
prefs.setReplayGainMode(ReplayGainMode.Auto)
|
||||
assertEquals(ReplayGainMode.Auto, prefs.replayGainMode().first())
|
||||
|
||||
prefs.setReplayGainPreampDb(99f)
|
||||
assertEquals(MAX_REPLAY_GAIN_PREAMP_DB, prefs.replayGainPreampDb().first(), 0f)
|
||||
|
||||
|
||||
@@ -3,13 +3,12 @@ package com.subtracks.data.repo
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.net.Uri
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkFetcher
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
@@ -26,6 +25,7 @@ import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.SubsonicSource
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -36,6 +36,10 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -49,6 +53,7 @@ import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DownloadRepositoryTest {
|
||||
@@ -65,11 +70,12 @@ class DownloadRepositoryTest {
|
||||
private val requestedArt = CopyOnWriteArrayList<String>()
|
||||
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
|
||||
private var failArtworkFetch = false
|
||||
private val failingArtUrls = CopyOnWriteArrayList<String>()
|
||||
private val fetcher =
|
||||
ArtworkFetcher { url ->
|
||||
requestedArt += url
|
||||
artFetchedAfterTheEngineRequest += engine.requests.isNotEmpty()
|
||||
if (failArtworkFetch) throw IOException("boom")
|
||||
if (failArtworkFetch || url in failingArtUrls) throw IOException("boom")
|
||||
byteArrayOf(1, 2, 3)
|
||||
}
|
||||
|
||||
@@ -77,11 +83,7 @@ class DownloadRepositoryTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
resources = context.resources
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
sources = SourceRepository(db, OkHttpClient(), prefs, artwork, scope = scope)
|
||||
@@ -121,6 +123,49 @@ class DownloadRepositoryTest {
|
||||
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDownloadAlsoFetchesTheArtistsBiography() =
|
||||
runTest {
|
||||
val server = MockWebServer()
|
||||
server.start()
|
||||
try {
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||
when (request.requestUrl!!.encodedPath) {
|
||||
"/rest/ping.view" -> {
|
||||
MockResponse().setBody("<subsonic-response status=\"ok\"/>")
|
||||
}
|
||||
|
||||
"/rest/getArtistInfo2.view" -> {
|
||||
MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><artistInfo2>" +
|
||||
"<biography>Downloaded bio</biography>" +
|
||||
"</artistInfo2></subsonic-response>",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
}
|
||||
seedLibrary(server.url("/").toString())
|
||||
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
|
||||
await { artistBio(1, "ar1") != null }
|
||||
assertEquals("Downloaded bio", artistBio(1, "ar1")?.biography)
|
||||
var request = server.takeRequest(5, TimeUnit.SECONDS)!!
|
||||
while (request.requestUrl!!.encodedPath != "/rest/getArtistInfo2.view") {
|
||||
request = server.takeRequest(5, TimeUnit.SECONDS)!!
|
||||
}
|
||||
assertEquals("ar1", request.requestUrl!!.queryParameter("id"))
|
||||
} finally {
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun progressAndCompletionAreMirroredAndTheFileBecomesPlayable() =
|
||||
runTest {
|
||||
@@ -296,7 +341,10 @@ class DownloadRepositoryTest {
|
||||
runTest {
|
||||
seedLibrary()
|
||||
writeFile(1, "s1", "audio")
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
runBlocking {
|
||||
repository.download(1, "s1")
|
||||
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART).copy(downloadedCoverArts = listOf(ALBUM_ART))))
|
||||
}
|
||||
val id = engine.requests.single().first
|
||||
|
||||
runBlocking { repository.removeSource(1) }
|
||||
@@ -304,6 +352,13 @@ class DownloadRepositoryTest {
|
||||
assertTrue(engine.cancelled.contains(id))
|
||||
assertFalse(file(1, "s1").exists())
|
||||
assertTrue(allRows().isEmpty())
|
||||
assertTrue(
|
||||
db
|
||||
.libraryDao()
|
||||
.artistOnce(1, "ar1")!!
|
||||
.downloadedCoverArts
|
||||
.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -333,6 +388,95 @@ class DownloadRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startingADownloadStoresTheProviderArtistImageAndKeepsItUntilTheDownloadGoes() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking {
|
||||
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg")))
|
||||
}
|
||||
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
|
||||
assertTrue(requestedArt.contains("https://img.example/ar1.jpg"))
|
||||
val key = artistImageKey(1, "ar1")
|
||||
assertNotNull(artwork.uri(1, key))
|
||||
|
||||
runBlocking { repository.reconcile() }
|
||||
assertNotNull(artwork.uri(1, key))
|
||||
|
||||
runBlocking { repository.remove(1, "s1") }
|
||||
assertNull(artwork.uri(1, key))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerArtworkForTheAlbumArtistSurvivesASweepAndGoesWithTheDownload() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking {
|
||||
db.libraryDao().upsertArtists(
|
||||
listOf(
|
||||
artist(ARTIST_ART, "https://img.example/track.jpg").copy(id = "ar-track"),
|
||||
artist(ARTIST_ART, "https://img.example/album.jpg").copy(id = "ar-album"),
|
||||
),
|
||||
)
|
||||
db.libraryDao().upsertAlbums(listOf(album(ALBUM_ART).copy(artistId = "ar-album")))
|
||||
db.libraryDao().upsertSongs(listOf(db.libraryDao().songOnce(1, "s1")!!.copy(artistId = "ar-track")))
|
||||
}
|
||||
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
val trackKey = artistImageKey(1, "ar-track")
|
||||
val albumKey = artistImageKey(1, "ar-album")
|
||||
assertNotNull(artwork.uri(1, trackKey))
|
||||
assertNotNull(artwork.uri(1, albumKey))
|
||||
|
||||
runBlocking { repository.reconcile() }
|
||||
assertNotNull(artwork.uri(1, trackKey))
|
||||
assertNotNull(artwork.uri(1, albumKey))
|
||||
|
||||
runBlocking { repository.remove(1, "s1") }
|
||||
assertNull(artwork.uri(1, trackKey))
|
||||
assertNull(artwork.uri(1, albumKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedProviderImageFetchCachesTheArtistCoverFallback() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking {
|
||||
db.libraryDao().upsertArtists(listOf(artist(ARTIST_ART, artistImageUrl = "https://img.example/ar1.jpg")))
|
||||
}
|
||||
failingArtUrls += "https://img.example/ar1.jpg"
|
||||
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
|
||||
assertNull(artwork.uri(1, artistImageKey(1, "ar1")))
|
||||
assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, false)))
|
||||
assertNotNull(artwork.uri(1, coverArtKey(1, ARTIST_ART, true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completingADownloadMaterializesOnlyItsAlbumForOfflineArtistArt() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
repository.start()
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
val id = engine.requests.single().first
|
||||
|
||||
engine.complete(id, total = 100)
|
||||
writeFile(1, "s1", "audio")
|
||||
runBlocking { repository.reconcile() }
|
||||
|
||||
await { row(1, "s1")?.status == DownloadStatus.Completed }
|
||||
assertEquals(
|
||||
listOf(ALBUM_ART),
|
||||
db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts,
|
||||
)
|
||||
|
||||
runBlocking { repository.remove(1, "s1") }
|
||||
assertEquals(emptyList<String>(), db.libraryDao().artistOnce(1, "ar1")!!.downloadedCoverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadingASongAlsoStoresTheArtworkOfPlaylistsThatContainIt() =
|
||||
runTest {
|
||||
@@ -647,7 +791,17 @@ class DownloadRepositoryTest {
|
||||
),
|
||||
)
|
||||
db.libraryDao().upsertArtists(
|
||||
listOf(Artist(sourceId = 1, id = "ar-guest", name = "Guest", albumCount = 0, starred = null, coverArt = null)),
|
||||
listOf(
|
||||
Artist(
|
||||
sourceId = 1,
|
||||
id = "ar-guest",
|
||||
name = "Guest",
|
||||
albumCount = 0,
|
||||
starred = null,
|
||||
coverArt = null,
|
||||
songCount = 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -682,7 +836,17 @@ class DownloadRepositoryTest {
|
||||
),
|
||||
)
|
||||
db.libraryDao().upsertArtists(
|
||||
listOf(Artist(sourceId = 1, id = "ar-guest", name = "Guest", albumCount = 0, starred = null, coverArt = null)),
|
||||
listOf(
|
||||
Artist(
|
||||
sourceId = 1,
|
||||
id = "ar-guest",
|
||||
name = "Guest",
|
||||
albumCount = 0,
|
||||
starred = null,
|
||||
coverArt = null,
|
||||
songCount = 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
|
||||
}
|
||||
@@ -910,7 +1074,9 @@ class DownloadRepositoryTest {
|
||||
),
|
||||
)
|
||||
db.libraryDao().upsertArtists(
|
||||
listOf(Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null)),
|
||||
listOf(
|
||||
Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null, songCount = 1),
|
||||
),
|
||||
)
|
||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
|
||||
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Queued))
|
||||
@@ -953,6 +1119,53 @@ class DownloadRepositoryTest {
|
||||
assertFalse(file(1, "s2").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadedSongsFollowTheAlbumDiscAndTrackOrder() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking {
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = "s0",
|
||||
albumId = "al1",
|
||||
artistId = "ar1",
|
||||
title = "Alpha",
|
||||
album = "Album",
|
||||
artist = "Artist",
|
||||
duration = 100,
|
||||
track = 3,
|
||||
disc = 1,
|
||||
starred = null,
|
||||
genre = null,
|
||||
),
|
||||
Song(
|
||||
sourceId = 1,
|
||||
id = "s3",
|
||||
albumId = "al1",
|
||||
artistId = "ar1",
|
||||
title = "Zulu",
|
||||
album = "Album",
|
||||
artist = "Artist",
|
||||
duration = 100,
|
||||
track = 1,
|
||||
disc = 2,
|
||||
starred = null,
|
||||
genre = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
listOf("s0", "s1", "s2", "s3").forEach {
|
||||
db.downloadDao().upsert(SongDownload(1, it, DownloadStatus.Completed))
|
||||
}
|
||||
}
|
||||
|
||||
val songs = runBlocking { repository.downloadedSongs(1).first() }
|
||||
|
||||
assertEquals(listOf("s1", "s2", "s0", "s3"), songs.map { it.songId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTreeGroupsAnAlbumUnderItsAlbumArtistNotEachTrackArtist() =
|
||||
runTest {
|
||||
@@ -1165,12 +1378,23 @@ class DownloadRepositoryTest {
|
||||
songCount = 2,
|
||||
)
|
||||
|
||||
private fun artist(coverArt: String?) =
|
||||
Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 1, starred = null, coverArt = coverArt)
|
||||
private fun artist(
|
||||
coverArt: String?,
|
||||
artistImageUrl: String? = null,
|
||||
) = Artist(
|
||||
sourceId = 1,
|
||||
id = "ar1",
|
||||
name = "Artist",
|
||||
albumCount = 1,
|
||||
starred = null,
|
||||
coverArt = coverArt,
|
||||
artistImageUrl = artistImageUrl,
|
||||
songCount = 2,
|
||||
)
|
||||
|
||||
private fun seedLibrary() {
|
||||
private fun seedLibrary(address: String = "http://localhost:4533/") {
|
||||
runBlocking {
|
||||
db.sourcesDao().upsertSource(Source(1, "source", "http://localhost:4533/", isActive = true, createdAt = 0))
|
||||
db.sourcesDao().upsertSource(Source(1, "source", address, isActive = true, createdAt = 0))
|
||||
db.sourcesDao().upsertSubsonicSource(SubsonicSource(1, "user", "pass"))
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
@@ -1220,6 +1444,11 @@ class DownloadRepositoryTest {
|
||||
songId: String,
|
||||
): SongDownload? = runBlocking { db.downloadDao().find(sourceId, songId) }
|
||||
|
||||
private fun artistBio(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
) = runBlocking { db.libraryDao().artistBioOnce(sourceId, artistId) }
|
||||
|
||||
private fun allRows(): List<SongDownload> = runBlocking { db.downloadDao().all() }
|
||||
|
||||
private fun writeFile(
|
||||
|
||||
@@ -4,15 +4,18 @@ import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import androidx.paging.PagingDataEvent
|
||||
import androidx.paging.PagingDataPresenter
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
@@ -59,11 +62,7 @@ class LibraryRepositoryTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
resources = context.resources
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
sourceRepository =
|
||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
||||
@@ -72,6 +71,7 @@ class LibraryRepositoryTest {
|
||||
db,
|
||||
sourceRepository,
|
||||
NetworkServerActionSink(sourceRepository),
|
||||
fakeUserPreferences(),
|
||||
showMessage = { messages += it.resolve(resources) },
|
||||
scope = scope,
|
||||
)
|
||||
@@ -203,6 +203,197 @@ class LibraryRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theDownloadedAlbumGridOnlyPresentsDownloadedRows() =
|
||||
runBlocking {
|
||||
val sourceId = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
|
||||
db.libraryDao().upsertAlbums(listOf(seedAlbum(sourceId, 1), seedAlbum(sourceId, 2)))
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
seedSong(sourceId).copy(id = "s1", albumId = "al-0001"),
|
||||
seedSong(sourceId).copy(id = "s2", albumId = "al-0002"),
|
||||
),
|
||||
)
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||
|
||||
val presenterDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
val presenter = RecordingPresenter(presenterDispatcher)
|
||||
val job =
|
||||
launch {
|
||||
repository
|
||||
.albums(
|
||||
sourceId = sourceId,
|
||||
sort = AlbumSort.Name,
|
||||
descending = false,
|
||||
starred = StarredFilter.Any,
|
||||
search = "",
|
||||
downloaded = true,
|
||||
).collect { presenter.collectFrom(it) }
|
||||
}
|
||||
try {
|
||||
awaitRefreshes(presenter, 1)
|
||||
assertEquals(1, presented(presenter, presenterDispatcher))
|
||||
} finally {
|
||||
job.cancelAndJoin()
|
||||
presenterDispatcher.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistAlbumsSeparatesAlbumCreditsFromTrackAppearances() =
|
||||
runBlocking {
|
||||
val sourceId = 1L
|
||||
db.sourcesDao().upsertSource(
|
||||
Source(id = sourceId, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
||||
)
|
||||
db.libraryDao().upsertAlbums(
|
||||
listOf(
|
||||
seedAlbum(sourceId, 1),
|
||||
seedAlbum(sourceId, 2).copy(artistId = "ar2"),
|
||||
),
|
||||
)
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
seedSong(sourceId).copy(albumId = "al-0001"),
|
||||
seedSong(sourceId).copy(id = "s2", albumId = "al-0002"),
|
||||
),
|
||||
)
|
||||
|
||||
val albums = repository.artistAlbums(sourceId, "ar1").first()
|
||||
|
||||
assertEquals(listOf("al-0001"), albums.albumCredits.map { it.id })
|
||||
assertEquals(listOf("al-0002"), albums.appearances.map { it.id })
|
||||
assertEquals(2, albums.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadedArtistAlbumsKeepPerCreditVisibility() =
|
||||
runBlocking {
|
||||
val sourceId = 1L
|
||||
db.sourcesDao().upsertSource(
|
||||
Source(id = sourceId, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
||||
)
|
||||
db.libraryDao().upsertAlbums(
|
||||
listOf(
|
||||
seedAlbum(sourceId, 1),
|
||||
seedAlbum(sourceId, 2).copy(artistId = "ar2"),
|
||||
),
|
||||
)
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
seedSong(sourceId).copy(albumId = "al-0001"),
|
||||
seedSong(sourceId).copy(id = "s2", albumId = "al-0002"),
|
||||
seedSong(sourceId).copy(id = "s3", albumId = "al-0002", artistId = "ar2"),
|
||||
seedSong(sourceId).copy(id = "s4", albumId = "al-0001", artistId = "ar2"),
|
||||
),
|
||||
)
|
||||
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s4", DownloadStatus.Completed))
|
||||
val otherOnAlbumCredit = repository.artistAlbums(sourceId, "ar1", downloaded = true).first()
|
||||
assertEquals(listOf("al-0001"), otherOnAlbumCredit.albumCredits.map { it.id })
|
||||
assertEquals(emptyList<String>(), otherOnAlbumCredit.appearances.map { it.id })
|
||||
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s3", DownloadStatus.Completed))
|
||||
val otherOnTrackCredit = repository.artistAlbums(sourceId, "ar1", downloaded = true).first()
|
||||
assertEquals(listOf("al-0001"), otherOnTrackCredit.albumCredits.map { it.id })
|
||||
assertEquals(emptyList<String>(), otherOnTrackCredit.appearances.map { it.id })
|
||||
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed))
|
||||
val creditedTrack = repository.artistAlbums(sourceId, "ar1", downloaded = true).first()
|
||||
assertEquals(listOf("al-0001"), creditedTrack.albumCredits.map { it.id })
|
||||
assertEquals(listOf("al-0002"), creditedTrack.appearances.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshingAnArtistBioStoresItAndUpdatesItOnTheNextOpen() =
|
||||
runBlocking {
|
||||
withSource { server ->
|
||||
val sourceId = sourceRepository.activeSourceIdOnce()!!
|
||||
server.enqueue(artistInfo("First bio"))
|
||||
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
|
||||
assertEquals("First bio", repository.artistBio(sourceId, "ar1").first()?.biography)
|
||||
|
||||
server.enqueue(artistInfo("Second bio"))
|
||||
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
|
||||
|
||||
assertEquals("Second bio", repository.artistBio(sourceId, "ar1").first()?.biography)
|
||||
val first = server.takeRequest()
|
||||
assertEquals("/rest/getArtistInfo2.view", first.requestUrl!!.encodedPath)
|
||||
assertEquals("ar1", first.requestUrl!!.queryParameter("id"))
|
||||
assertEquals("/rest/getArtistInfo2.view", server.takeRequest().requestUrl!!.encodedPath)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anArtistWithoutABioStillRecordsTheFetchedState() =
|
||||
runBlocking {
|
||||
withSource { server ->
|
||||
val sourceId = sourceRepository.activeSourceIdOnce()!!
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><artistInfo2/></subsonic-response>",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
|
||||
|
||||
val bio = repository.artistBio(sourceId, "ar1").first()
|
||||
assertNotNull(bio)
|
||||
assertNull(bio?.biography)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEmptyRefreshKeepsTheStoredBio() =
|
||||
runBlocking {
|
||||
withSource { server ->
|
||||
val sourceId = sourceRepository.activeSourceIdOnce()!!
|
||||
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, "ar1", "Kept"))
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><artistInfo2/></subsonic-response>",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isSuccess)
|
||||
|
||||
assertEquals("Kept", repository.artistBio(sourceId, "ar1").first()?.biography)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun biographiesKeepLineAndParagraphBreaks() {
|
||||
assertEquals("Line one\nLine two", normalizeBiography("Line one\r\nLine two"))
|
||||
assertEquals("Line one\nLine two", normalizeBiography("Line one\nLine two"))
|
||||
assertEquals("One\n\nTwo", normalizeBiography("One Two"))
|
||||
assertEquals("One\n\nTwo", normalizeBiography("One\n\nTwo"))
|
||||
assertNull(normalizeBiography(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPreviouslyStoredBioIsNormalizedWhenRead() =
|
||||
runBlocking {
|
||||
withSource {
|
||||
val sourceId = sourceRepository.activeSourceIdOnce()!!
|
||||
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, "ar1", "One Two"))
|
||||
assertEquals("One\n\nTwo", repository.artistBio(sourceId, "ar1").first()?.biography)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedRefreshKeepsTheStoredBio() =
|
||||
runBlocking {
|
||||
withSource { server ->
|
||||
val sourceId = sourceRepository.activeSourceIdOnce()!!
|
||||
db.libraryDao().upsertArtistBio(ArtistBio(sourceId, "ar1", "Kept"))
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
|
||||
assertTrue(repository.refreshArtistBio(sourceId, "ar1").isFailure)
|
||||
|
||||
assertEquals("Kept", repository.artistBio(sourceId, "ar1").first()?.biography)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitRefreshes(
|
||||
presenter: RecordingPresenter,
|
||||
target: Int,
|
||||
@@ -279,6 +470,13 @@ class LibraryRepositoryTest {
|
||||
genre = null,
|
||||
)
|
||||
|
||||
private fun artistInfo(biography: String) =
|
||||
MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><artistInfo2>" +
|
||||
"<biography>$biography</biography>" +
|
||||
"</artistInfo2></subsonic-response>",
|
||||
)
|
||||
|
||||
private fun ok() = MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")
|
||||
|
||||
private fun failed(code: Int) =
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package com.subtracks.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.PendingActionKind
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
@@ -82,12 +79,7 @@ class PendingServerActionSinkTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
sourceId = runBlocking { db.sourcesDao().insertSource(Source(name = "server", address = "http://x/", createdAt = 0)) }
|
||||
delegate = RecordingSink()
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
package com.subtracks.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useReaderConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
@@ -26,12 +23,7 @@ class QueueKeysetTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
repository = QueueRepository(db)
|
||||
}
|
||||
|
||||
@@ -158,6 +150,18 @@ class QueueKeysetTest {
|
||||
assertTrue("backward keyset seeks", repository.keysetSeeks > beforeBackward)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scanningAnAlbumWithNullDiscAndTrackForwardFallsBackToOffsetRows() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
repository.replace(listOf(repository.albumEntry(1, "mixed")))
|
||||
val snapshot = repository.snapshot()
|
||||
|
||||
val resolved = (0 until snapshot.size).map { repository.itemAt(snapshot, it)?.song?.id }
|
||||
|
||||
assertEquals(listOf("n1", "n2", "s1", "s2"), resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scanningAnAlbumWithNullDiscAndTrackBackwardsResolvesEveryRow() =
|
||||
runTest {
|
||||
@@ -268,6 +272,22 @@ class QueueKeysetTest {
|
||||
assertTrue(ids.all { it in expected })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thePlaylistWindowQuerySeeksThePositionIndex() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val plan =
|
||||
queryPlan(
|
||||
"SELECT songs.*, playlist_songs.position AS playlistPosition, albums.coverArt AS coverArt " +
|
||||
"FROM playlist_songs JOIN songs ON songs.sourceId = playlist_songs.sourceId " +
|
||||
"AND songs.id = playlist_songs.songId LEFT JOIN albums ON albums.sourceId = songs.sourceId " +
|
||||
"AND albums.id = songs.albumId WHERE playlist_songs.sourceId = 1 " +
|
||||
"AND playlist_songs.playlistId = 'pl1' ORDER BY playlist_songs.position LIMIT 51 OFFSET 100000",
|
||||
)
|
||||
|
||||
assertTrue(plan.joinToString("\n"), plan.any { it.contains("sqlite_autoindex_playlist_songs_1") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theAlbumKeysetQuerySeeksTheOrderIndex() =
|
||||
runTest {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package com.subtracks.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useReaderConnection
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
@@ -33,12 +30,7 @@ class QueueRepositoryTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
repository = QueueRepository(db)
|
||||
}
|
||||
|
||||
@@ -485,6 +477,80 @@ class QueueRepositoryTest {
|
||||
repository.move(repository.snapshot(), from = 2, to = 1)
|
||||
|
||||
assertEquals(listOf("s1", "s5", "s4", "s2", "s3"), resolveAll(repository.snapshot()))
|
||||
val (entry, _) = repository.snapshot().locate(1)!!
|
||||
assertEquals(QueueKind.Song, entry.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAQueuedAlbumTrackKeepsItsAlbumEntry() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = repository.albumEntry(1, "al2"), playNext = false)
|
||||
|
||||
repository.move(repository.snapshot(), from = 2, to = 1)
|
||||
|
||||
val (entry, _) = repository.snapshot().locate(1)!!
|
||||
assertEquals(QueueKind.Album, entry.kind)
|
||||
assertEquals("al2", entry.refId)
|
||||
assertEquals(1L, entry.rangeStart)
|
||||
assertEquals(1L, entry.rangeEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingARangedAlbumTrackInUpNextKeepsTheAbsoluteOffsets() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
song("s6", "al1", track = 4, album = "First Album"),
|
||||
song("s7", "al1", track = 5, album = "First Album"),
|
||||
song("x1", "al3", track = 1),
|
||||
),
|
||||
)
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al2")))
|
||||
repository.addUpNext(snapshot, currentPosition = 0, entry = albumRange(1, 4), playNext = false)
|
||||
repository.addUpNext(
|
||||
repository.snapshot(),
|
||||
currentPosition = 0,
|
||||
entry = repository.songEntry(1, "x1"),
|
||||
playNext = false,
|
||||
)
|
||||
assertEquals(listOf("s4", "s2", "s3", "s6", "s7", "x1", "s5"), resolveAll(repository.snapshot()))
|
||||
|
||||
repository.move(repository.snapshot(), from = 4, to = 5)
|
||||
|
||||
var updated = repository.snapshot()
|
||||
assertEquals(listOf("s4", "s2", "s3", "s6", "x1", "s7", "s5"), resolveAll(updated))
|
||||
val moved = updated.locate(5)!!.first
|
||||
assertEquals(QueueKind.Album, moved.kind)
|
||||
assertEquals(4L, moved.rangeStart)
|
||||
assertEquals(4L, moved.rangeEnd)
|
||||
|
||||
repository.move(repository.snapshot(), from = 5, to = 4)
|
||||
|
||||
updated = repository.snapshot()
|
||||
assertEquals(listOf("s4", "s2", "s3", "s6", "s7", "x1", "s5"), resolveAll(updated))
|
||||
assertTrue(updated.isAlbumContext(4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAPlaylistTrackKeepsItAPinnedSong() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertPlaylistSongs(
|
||||
listOf(
|
||||
PlaylistSong(1, "p1", "s1", 0),
|
||||
PlaylistSong(1, "p1", "s4", 1),
|
||||
),
|
||||
)
|
||||
repository.snapshotAfter(listOf(repository.playlistEntry(1, "p1")))
|
||||
|
||||
repository.move(repository.snapshot(), from = 0, to = 1)
|
||||
|
||||
val (entry, _) = repository.snapshot().locate(1)!!
|
||||
assertEquals(QueueKind.Song, entry.kind)
|
||||
assertEquals("s1", entry.refId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -661,6 +727,32 @@ class QueueRepositoryTest {
|
||||
assertEquals(listOf("s1", "s2", "s3"), resolveAll(restored))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shuffledLargeContextWindowOnlyResolvesRequestedRows() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertPlaylists(listOf(playlist("huge", "Huge")))
|
||||
db.useWriterConnection { transactor ->
|
||||
transactor.usePrepared(
|
||||
"WITH RECURSIVE n(x) AS (VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 199999) " +
|
||||
"INSERT INTO playlist_songs SELECT 1, 'huge', 's1', x FROM n",
|
||||
) { statement ->
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
repository.snapshotAfter(listOf(repository.playlistEntry(1, "huge")))
|
||||
repository.setShuffle(42L)
|
||||
val snapshot = repository.snapshot()
|
||||
|
||||
val window = repository.window(snapshot, center = 100_000, radius = 25)
|
||||
|
||||
assertEquals(200_000L, snapshot.contextSize)
|
||||
assertEquals(51, window.size)
|
||||
assertEquals(51L, repository.resolvedRowCount)
|
||||
assertEquals((99_975L..100_025L).toList(), window.map { it.position })
|
||||
assertTrue(window.all { it.item.song.id == "s1" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shuffledRangeResolvesEntriesInPositionOrder() =
|
||||
runTest {
|
||||
@@ -785,6 +877,55 @@ class QueueRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextIndexUsesTheFirstDuplicateOrdinalAcrossSparsePositions() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
|
||||
db.libraryDao().upsertPlaylistSongs(
|
||||
listOf(
|
||||
PlaylistSong(1, "duplicates", "s2", 10),
|
||||
PlaylistSong(1, "duplicates", "s1", 20),
|
||||
PlaylistSong(1, "duplicates", "s2", 30),
|
||||
),
|
||||
)
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "duplicates")))
|
||||
|
||||
assertEquals(0L, repository.combinedContextIndexOf(snapshot, "s2"))
|
||||
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextIndexFindsADuplicateInsideTheEntryRange() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
|
||||
db.libraryDao().upsertPlaylistSongs(
|
||||
listOf(
|
||||
PlaylistSong(1, "duplicates", "s2", 10),
|
||||
PlaylistSong(1, "duplicates", "s1", 20),
|
||||
PlaylistSong(1, "duplicates", "s2", 30),
|
||||
),
|
||||
)
|
||||
val entry = repository.playlistEntry(1, "duplicates").copy(rangeStart = 1, rangeEnd = 2)
|
||||
val snapshot = repository.snapshotAfter(listOf(entry))
|
||||
|
||||
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextIndexFallsBackToBoundedRowsForGenre() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
insertGenre("s1", "Rock")
|
||||
insertGenre("s3", "Rock")
|
||||
insertGenre("s4", "Rock")
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.genreEntry(1, "Rock")))
|
||||
|
||||
assertEquals(2L, repository.combinedContextIndexOf(snapshot, "s4"))
|
||||
assertTrue(repository.resolvedRowCount <= QUEUE_CHUNK)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upNextCombinedIndexOfCountsFromTheAnchorIntoTheBlock() =
|
||||
runTest {
|
||||
@@ -853,11 +994,75 @@ class QueueRepositoryTest {
|
||||
repository.move(repository.snapshot(), from = 0, to = 2)
|
||||
|
||||
val (entry, _) = repository.snapshot().locate(2)!!
|
||||
assertEquals(QueueKind.Song, entry.kind)
|
||||
assertEquals("s1", entry.refId)
|
||||
assertEquals(QueueKind.Album, entry.kind)
|
||||
assertEquals("al1", entry.refId)
|
||||
assertEquals(0L, entry.rangeStart)
|
||||
assertEquals(0L, entry.rangeEnd)
|
||||
assertEquals("First Album", repository.sourceName(entry.kind, entry.sourceId, entry.refId))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingWithinARangedAlbumKeepsTheAbsoluteOffsets() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
song("s6", "al1", track = 4, album = "First Album"),
|
||||
song("s7", "al1", track = 5, album = "First Album"),
|
||||
),
|
||||
)
|
||||
repository.snapshotAfter(listOf(albumRange(1, 4)))
|
||||
assertEquals(listOf("s2", "s3", "s6", "s7"), resolveAll(repository.snapshot()))
|
||||
|
||||
repository.move(repository.snapshot(), from = 0, to = 2)
|
||||
|
||||
val snapshot = repository.snapshot()
|
||||
assertEquals(listOf("s3", "s6", "s2", "s7"), resolveAll(snapshot))
|
||||
val (entry, _) = snapshot.locate(2)!!
|
||||
assertEquals(QueueKind.Album, entry.kind)
|
||||
assertEquals(1L, entry.rangeStart)
|
||||
assertEquals(1L, entry.rangeEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingBackwardWithinARangedAlbumKeepsTheAbsoluteOffsets() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
song("s6", "al1", track = 4, album = "First Album"),
|
||||
song("s7", "al1", track = 5, album = "First Album"),
|
||||
),
|
||||
)
|
||||
repository.snapshotAfter(listOf(albumRange(1, 4)))
|
||||
|
||||
repository.move(repository.snapshot(), from = 3, to = 0)
|
||||
|
||||
val snapshot = repository.snapshot()
|
||||
assertEquals(listOf("s7", "s2", "s3", "s6"), resolveAll(snapshot))
|
||||
val (entry, _) = snapshot.locate(0)!!
|
||||
assertEquals(QueueKind.Album, entry.kind)
|
||||
assertEquals(4L, entry.rangeStart)
|
||||
assertEquals(4L, entry.rangeEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingAnAlbumTrackBackCompactsTheAlbumEntry() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
repository.replace(listOf(repository.albumEntry(1, "al1")))
|
||||
|
||||
repository.move(repository.snapshot(), from = 0, to = 2)
|
||||
repository.move(repository.snapshot(), from = 2, to = 0)
|
||||
|
||||
val snapshot = repository.snapshot()
|
||||
assertEquals(listOf("s1", "s2", "s3"), resolveAll(snapshot))
|
||||
val entry = snapshot.entries.single().entry
|
||||
assertEquals(QueueKind.Album, entry.kind)
|
||||
assertEquals(0L, entry.rangeStart)
|
||||
assertEquals(2L, entry.rangeEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAlbumSplitByRemovalIsStillLabeledByItsAlbum() =
|
||||
runTest {
|
||||
|
||||
@@ -2,15 +2,15 @@ package com.subtracks.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.TEST_TIMEOUT_MS
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
@@ -33,6 +33,7 @@ import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -56,11 +57,7 @@ class SourceRepositoryTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
resources = context.resources
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
prefs = fakeUserPreferences()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@@ -283,7 +280,36 @@ class SourceRepositoryTest {
|
||||
assertTrue("expected the stored file, was ${ref.url}", ref.url.startsWith("file:"))
|
||||
}
|
||||
|
||||
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! }
|
||||
@Test
|
||||
fun activeSourceBecomesReadyAfterThePreferencesLoad() =
|
||||
runBlocking {
|
||||
repository.addSource("nav", "http://a.example/", "u", "p", false)
|
||||
|
||||
val active = withTimeout(TEST_TIMEOUT_MS) { repository.activeSource.first { it != null } }
|
||||
assertEquals(repository.activeSourceId().first(), active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistImageUsesTheNetworkOnlineAndOnlyCachedFilesOffline() =
|
||||
runBlocking {
|
||||
repository.addSource("nav", "http://a.example/", "u", "p", false)
|
||||
val sourceId = awaitActiveSourceId()
|
||||
val url = "https://img.example/ar1.jpg"
|
||||
val online = withTimeout(TEST_TIMEOUT_MS) { repository.artistImage("ar1", url) }
|
||||
assertEquals(url, online?.url)
|
||||
|
||||
val key = artistImageKey(sourceId, "ar1")
|
||||
artwork.write(sourceId, key, byteArrayOf(1, 2, 3))
|
||||
assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url)
|
||||
|
||||
repository.setOfflineMode(true)
|
||||
withTimeout(TEST_TIMEOUT_MS) { repository.offline.first { it } }
|
||||
|
||||
assertEquals(artwork.uri(sourceId, key), repository.artistImage("ar1", url)?.url)
|
||||
assertNull(repository.artistImage("ar2", "https://img.example/ar2.jpg"))
|
||||
}
|
||||
|
||||
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSource.first { it != null }!! }
|
||||
|
||||
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
|
||||
withTimeout(TEST_TIMEOUT_MS) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user